Compare commits
4 Commits
cf7cfcd86b
...
86714e7837
| Author | SHA1 | Date |
|---|---|---|
|
|
86714e7837 | |
|
|
b1b059de8c | |
|
|
8b2a8888d3 | |
|
|
eb0ebf5411 |
61
README.md
61
README.md
|
|
@ -8,7 +8,6 @@ Build turn-based board games with reactive state, entity collections, spatial re
|
||||||
|
|
||||||
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/)
|
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/)
|
||||||
- **Type Safe**: Full TypeScript support with strict mode and generic context extension
|
- **Type Safe**: Full TypeScript support with strict mode and generic context extension
|
||||||
- **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.)
|
|
||||||
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
|
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
|
||||||
- **Command System**: CLI-style command parsing with schema validation, type coercion, and prompt support
|
- **Command System**: CLI-style command parsing with schema validation, type coercion, and prompt support
|
||||||
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
|
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
|
||||||
|
|
@ -28,13 +27,14 @@ Each game defines a command registry and exports a `createInitialState` function
|
||||||
```ts
|
```ts
|
||||||
import {
|
import {
|
||||||
createGameCommandRegistry,
|
createGameCommandRegistry,
|
||||||
RegionEntity,
|
Region,
|
||||||
Entity,
|
createRegion,
|
||||||
} from 'boardgame-core';
|
} from 'boardgame-core';
|
||||||
|
|
||||||
// 1. Define your game-specific state
|
// 1. Define your game-specific state
|
||||||
type MyGameState = {
|
type MyGameState = {
|
||||||
board: RegionEntity;
|
board: Region;
|
||||||
|
parts: Record<string, { id: string; regionId: string; position: number[] }>;
|
||||||
score: { white: number; black: number };
|
score: { white: number; black: number };
|
||||||
currentPlayer: 'white' | 'black';
|
currentPlayer: 'white' | 'black';
|
||||||
winner: 'white' | 'black' | 'draw' | null;
|
winner: 'white' | 'black' | 'draw' | null;
|
||||||
|
|
@ -43,14 +43,11 @@ type MyGameState = {
|
||||||
// 2. Create initial state factory
|
// 2. Create initial state factory
|
||||||
export function createInitialState(): MyGameState {
|
export function createInitialState(): MyGameState {
|
||||||
return {
|
return {
|
||||||
board: new RegionEntity('board', {
|
board: createRegion('board', [
|
||||||
id: 'board',
|
|
||||||
axes: [
|
|
||||||
{ name: 'x', min: 0, max: 5 },
|
{ name: 'x', min: 0, max: 5 },
|
||||||
{ name: 'y', min: 0, max: 5 },
|
{ name: 'y', min: 0, max: 5 },
|
||||||
],
|
]),
|
||||||
children: [],
|
parts: {},
|
||||||
}),
|
|
||||||
score: { white: 0, black: 0 },
|
score: { white: 0, black: 0 },
|
||||||
currentPlayer: 'white',
|
currentPlayer: 'white',
|
||||||
winner: null,
|
winner: null,
|
||||||
|
|
@ -110,12 +107,7 @@ const promptEvent = await game.commands.promptQueue.pop();
|
||||||
console.log(promptEvent.schema.name); // e.g. 'play'
|
console.log(promptEvent.schema.name); // e.g. 'play'
|
||||||
|
|
||||||
// Validate and submit player input
|
// Validate and submit player input
|
||||||
const error = promptEvent.tryCommit({
|
const error = promptEvent.tryCommit('play X 1 2');
|
||||||
name: 'play',
|
|
||||||
params: ['X', 1, 1],
|
|
||||||
options: {},
|
|
||||||
flags: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log('Invalid move:', error);
|
console.log('Invalid move:', error);
|
||||||
|
|
@ -149,13 +141,16 @@ See [`src/samples/boop/index.ts`](src/samples/boop/index.ts).
|
||||||
## Region System
|
## Region System
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { RegionEntity, applyAlign, shuffle } from 'boardgame-core';
|
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core';
|
||||||
|
|
||||||
// Compact cards in a hand towards the start
|
// Compact cards in a hand towards the start
|
||||||
applyAlign(handRegion);
|
applyAlign(handRegion, parts);
|
||||||
|
|
||||||
// Shuffle positions of all parts in a region
|
// Shuffle positions of all parts in a region
|
||||||
shuffle(handRegion, rng);
|
shuffle(handRegion, parts, rng);
|
||||||
|
|
||||||
|
// Move a part from one region to another
|
||||||
|
moveToRegion(part, sourceRegion, targetRegion, [0, 0]);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Command Parsing
|
## Command Parsing
|
||||||
|
|
@ -173,20 +168,6 @@ const result = validateCommand(cmd, schema);
|
||||||
// { valid: true }
|
// { valid: true }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entity Collections
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createEntityCollection } from 'boardgame-core';
|
|
||||||
|
|
||||||
const collection = createEntityCollection();
|
|
||||||
collection.add({ id: 'a', name: 'Item A' }, { id: 'b', name: 'Item B' });
|
|
||||||
|
|
||||||
const accessor = collection.get('a');
|
|
||||||
console.log(accessor.value); // reactive access
|
|
||||||
|
|
||||||
collection.remove('a');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Random Number Generation
|
## Random Number Generation
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -223,13 +204,14 @@ rng.setSeed(999); // reseed
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `RegionEntity` | Entity type for spatial grouping of parts with axis-based positioning |
|
| `Region` | Type for spatial grouping of parts with axis-based positioning |
|
||||||
| `RegionAxis` | Axis definition with min/max/align |
|
| `RegionAxis` | Axis definition with min/max/align |
|
||||||
| `applyAlign(region)` | Compact parts according to axis alignment |
|
| `createRegion(id, axes)` | Create a new region |
|
||||||
| `shuffle(region, rng)` | Randomize part positions |
|
| `applyAlign(region, parts)` | Compact parts according to axis alignment |
|
||||||
| `moveToRegion(part, targetRegion, position?)` | Move a part to another region |
|
| `shuffle(region, parts, rng)` | Randomize part positions |
|
||||||
| `moveToRegionAll(parts, targetRegion, positions?)` | Move multiple parts to another region |
|
| `moveToRegion(part, sourceRegion, targetRegion, position?)` | Move a part to another region |
|
||||||
| `removeFromRegion(part)` | Remove a part from its region |
|
| `moveToRegionAll(parts, sourceRegion, targetRegion, positions?)` | Move multiple parts to another region |
|
||||||
|
| `removeFromRegion(part, region)` | Remove a part from its region |
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
|
|
@ -254,7 +236,6 @@ rng.setSeed(999); // reseed
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createEntityCollection<T>()` | Create a reactive entity collection |
|
|
||||||
| `createRNG(seed?)` | Create a seeded RNG instance |
|
| `createRNG(seed?)` | Create a seeded RNG instance |
|
||||||
| `Mulberry32RNG` | Mulberry32 PRNG class |
|
| `Mulberry32RNG` | Mulberry32 PRNG class |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {entity, Entity} from "@/utils/entity";
|
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
|
|
@ -12,16 +12,16 @@ import {
|
||||||
} from "@/utils/command";
|
} from "@/utils/command";
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
state: Entity<TState>;
|
state: MutableSignal<TState>;
|
||||||
commands: CommandRunnerContextExport<Entity<TState>>;
|
commands: CommandRunnerContextExport<MutableSignal<TState>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
commandRegistry: CommandRegistry<Entity<TState>>,
|
commandRegistry: CommandRegistry<MutableSignal<TState>>,
|
||||||
initialState?: TState | (() => TState)
|
initialState?: TState | (() => TState)
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
||||||
const state = entity('state', stateValue);
|
const state = mutableSignal(stateValue);
|
||||||
const commands = createCommandRunnerContext(commandRegistry, state);
|
const commands = createCommandRunnerContext(commandRegistry, state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -36,7 +36,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
*/
|
*/
|
||||||
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
|
export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
|
||||||
module: {
|
module: {
|
||||||
registry: CommandRegistry<Entity<TState>>,
|
registry: CommandRegistry<MutableSignal<TState>>,
|
||||||
createInitialState: () => TState
|
createInitialState: () => TState
|
||||||
},
|
},
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
|
|
@ -44,12 +44,12 @@ export function createGameContextFromModule<TState extends Record<string, unknow
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
||||||
const registry = createCommandRegistry<Entity<TState>>();
|
const registry = createCommandRegistry<MutableSignal<TState>>();
|
||||||
return {
|
return {
|
||||||
registry,
|
registry,
|
||||||
add<TResult = unknown>(
|
add<TResult = unknown>(
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
|
run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
|
||||||
){
|
){
|
||||||
createGameCommand(registry, schema, run);
|
createGameCommand(registry, schema, run);
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -58,9 +58,9 @@ export function createGameCommandRegistry<TState extends Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
||||||
registry: CommandRegistry<Entity<TState>>,
|
registry: CommandRegistry<MutableSignal<TState>>,
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
|
run: (this: CommandRunnerContext<MutableSignal<TState>>, command: Command) => Promise<TResult>
|
||||||
) {
|
) {
|
||||||
registerCommand(registry, {
|
registerCommand(registry, {
|
||||||
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import {Entity} from "@/utils/entity";
|
|
||||||
import {Region} from "./region";
|
|
||||||
import {RNG} from "@/utils/rng";
|
import {RNG} from "@/utils/rng";
|
||||||
|
|
||||||
export type Part = {
|
export type Part = {
|
||||||
|
|
@ -10,27 +8,21 @@ export type Part = {
|
||||||
|
|
||||||
alignments?: string[];
|
alignments?: string[];
|
||||||
alignment?: string;
|
alignment?: string;
|
||||||
region: Entity<Region>;
|
regionId: string;
|
||||||
position: number[];
|
position: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flip(part: Entity<Part>) {
|
export function flip(part: Part) {
|
||||||
part.produce(draft => {
|
if(!part.sides) return;
|
||||||
if(!draft.sides)return;
|
part.side = ((part.side || 0) + 1) % part.sides;
|
||||||
draft.side = ((draft.side||0) + 1) % draft.sides;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flipTo(part: Entity<Part>, side: number) {
|
export function flipTo(part: Part, side: number) {
|
||||||
part.produce(draft => {
|
if(!part.sides || side >= part.sides) return;
|
||||||
if(!draft.sides || side >= draft.sides)return;
|
part.side = side;
|
||||||
draft.side = side;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roll(part: Entity<Part>, rng: RNG) {
|
export function roll(part: Part, rng: RNG) {
|
||||||
part.produce(draft => {
|
if(!part.sides) return;
|
||||||
if(!draft.sides)return;
|
part.side = rng.nextInt(part.sides);
|
||||||
draft.side = rng.nextInt(draft.sides);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import {batch, computed, ReadonlySignal, SignalOptions} from "@preact/signals-core";
|
|
||||||
import {Entity} from "@/utils/entity";
|
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {RNG} from "@/utils/rng";
|
import {RNG} from "@/utils/rng";
|
||||||
|
|
||||||
export type Region = {
|
export type Region = {
|
||||||
id: string;
|
id: string;
|
||||||
axes: RegionAxis[];
|
axes: RegionAxis[];
|
||||||
children: Entity<Part>[];
|
childIds: string[];
|
||||||
|
partMap: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegionAxis = {
|
export type RegionAxis = {
|
||||||
|
|
@ -16,38 +15,39 @@ export type RegionAxis = {
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegionEntity extends Entity<Region> {
|
export function createRegion(id: string, axes: RegionAxis[]): Region {
|
||||||
public readonly partsMap: ReadonlySignal<Record<string, Entity<Part>>>;
|
return {
|
||||||
|
id,
|
||||||
public constructor(id: string, t?: Region, options?: SignalOptions<Region>) {
|
axes,
|
||||||
super(id, t, options);
|
childIds: [],
|
||||||
this.partsMap = computed(() => {
|
partMap: {},
|
||||||
const result: Record<string, Entity<Part>> = {};
|
};
|
||||||
for (const child of this.value.children) {
|
|
||||||
const key = child.value.position.join(',');
|
|
||||||
result[key] = child;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAlign(region: Entity<Region>) {
|
function buildPartMap(region: Region, parts: Record<string, Part>) {
|
||||||
batch(() => {
|
const map: Record<string, string> = {};
|
||||||
region.produce(applyAlignCore);
|
for (const childId of region.childIds) {
|
||||||
});
|
const part = parts[childId];
|
||||||
|
if (part) {
|
||||||
|
map[part.position.join(',')] = childId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAlignCore(region: Region) {
|
export function applyAlign(region: Region, parts: Record<string, Part>) {
|
||||||
if (region.children.length === 0) return;
|
if (region.childIds.length === 0) return;
|
||||||
|
|
||||||
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
||||||
const axis = region.axes[axisIndex];
|
const axis = region.axes[axisIndex];
|
||||||
if (!axis.align) continue;
|
if (!axis.align) continue;
|
||||||
|
|
||||||
const positionValues = new Set<number>();
|
const positionValues = new Set<number>();
|
||||||
for (const child of region.children) {
|
for (const childId of region.childIds) {
|
||||||
positionValues.add(child.value.position[axisIndex] ?? 0);
|
const part = parts[childId];
|
||||||
|
if (part) {
|
||||||
|
positionValues.add(part.position[axisIndex] ?? 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
||||||
|
|
@ -75,86 +75,78 @@ function applyAlignCore(region: Region) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of region.children) {
|
for (const childId of region.childIds) {
|
||||||
child.produce(draft => {
|
const part = parts[childId];
|
||||||
const currentPos = draft.position[axisIndex] ?? 0;
|
if (part) {
|
||||||
draft.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
const currentPos = part.position[axisIndex] ?? 0;
|
||||||
});
|
part.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
region.children.sort((a, b) => {
|
region.childIds.sort((aId, bId) => {
|
||||||
|
const a = parts[aId];
|
||||||
|
const b = parts[bId];
|
||||||
|
if (!a || !b) return 0;
|
||||||
for (let i = 0; i < region.axes.length; i++) {
|
for (let i = 0; i < region.axes.length; i++) {
|
||||||
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
|
const diff = (a.position[i] ?? 0) - (b.position[i] ?? 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle(region: Entity<Region>, rng: RNG) {
|
export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
|
||||||
batch(() => {
|
if (region.childIds.length <= 1) return;
|
||||||
region.produce(region => shuffleCore(region, rng));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffleCore(region: Region, rng: RNG){
|
const childIds = [...region.childIds];
|
||||||
if (region.children.length <= 1) return;
|
for (let i = childIds.length - 1; i > 0; i--) {
|
||||||
|
|
||||||
const children = [...region.children];
|
|
||||||
for (let i = children.length - 1; i > 0; i--) {
|
|
||||||
const j = rng.nextInt(i + 1);
|
const j = rng.nextInt(i + 1);
|
||||||
const posI = [...children[i].value.position];
|
const partI = parts[childIds[i]];
|
||||||
const posJ = [...children[j].value.position];
|
const partJ = parts[childIds[j]];
|
||||||
children[i].produce(draft => {
|
if (!partI || !partJ) continue;
|
||||||
draft.position = posJ;
|
|
||||||
});
|
const posI = [...partI.position];
|
||||||
children[j].produce(draft => {
|
const posJ = [...partJ.position];
|
||||||
draft.position = posI;
|
partI.position = posJ;
|
||||||
});
|
partJ.position = posI;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveToRegion(part: Entity<Part>, targetRegion: Entity<Region>, position?: number[]) {
|
region.partMap = buildPartMap(region, parts);
|
||||||
const sourceRegion = part.value.region;
|
|
||||||
batch(() => {
|
|
||||||
sourceRegion.produce(draft => {
|
|
||||||
draft.children = draft.children.filter(c => c.id !== part.id);
|
|
||||||
});
|
|
||||||
targetRegion.produce(draft => {
|
|
||||||
draft.children.push(part);
|
|
||||||
});
|
|
||||||
part.produce(draft => {
|
|
||||||
draft.region = targetRegion;
|
|
||||||
if (position) draft.position = position;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveToRegionAll(parts: Entity<Part>[], targetRegion: Entity<Region>, positions?: number[][]) {
|
export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Region, position?: number[]) {
|
||||||
batch(() => {
|
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
||||||
|
delete sourceRegion.partMap[part.position.join(',')];
|
||||||
|
|
||||||
|
targetRegion.childIds.push(part.id);
|
||||||
|
if (position) {
|
||||||
|
part.position = position;
|
||||||
|
}
|
||||||
|
targetRegion.partMap[part.position.join(',')] = part.id;
|
||||||
|
|
||||||
|
part.regionId = targetRegion.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegion: Region, positions?: number[][]) {
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
const sourceRegion = part.value.region;
|
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
||||||
sourceRegion.produce(draft => {
|
delete sourceRegion.partMap[part.position.join(',')];
|
||||||
draft.children = draft.children.filter(c => c.id !== part.id);
|
|
||||||
});
|
targetRegion.childIds.push(part.id);
|
||||||
targetRegion.produce(draft => {
|
if (positions && positions[i]) {
|
||||||
draft.children.push(part);
|
part.position = positions[i];
|
||||||
});
|
}
|
||||||
part.produce(draft => {
|
targetRegion.partMap[part.position.join(',')] = part.id;
|
||||||
draft.region = targetRegion;
|
|
||||||
if (positions && positions[i]) draft.position = positions[i];
|
part.regionId = targetRegion.id;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeFromRegion(part: Entity<Part>) {
|
export function removeFromRegion(part: Part, region: Region) {
|
||||||
const region = part.value.region;
|
region.childIds = region.childIds.filter(id => id !== part.id);
|
||||||
batch(() => {
|
delete region.partMap[part.position.join(',')];
|
||||||
region.produce(draft => {
|
|
||||||
draft.children = draft.children.filter(c => c.id !== part.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ export type { Part } from './core/part';
|
||||||
export { flip, flipTo, roll } from './core/part';
|
export { flip, flipTo, roll } from './core/part';
|
||||||
|
|
||||||
export type { Region, RegionAxis } from './core/region';
|
export type { Region, RegionAxis } from './core/region';
|
||||||
export { applyAlign, shuffle, RegionEntity, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
|
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||||
|
|
@ -20,8 +20,8 @@ export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSche
|
||||||
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command';
|
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command';
|
||||||
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
|
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
|
||||||
|
|
||||||
export type { Entity } from './utils/entity';
|
export type { MutableSignal } from './utils/mutable-signal';
|
||||||
export { createEntityCollection, entity } from './utils/entity';
|
export { mutableSignal, createEntityCollection } from './utils/mutable-signal';
|
||||||
|
|
||||||
export type { RNG } from './utils/rng';
|
export type { RNG } from './utils/rng';
|
||||||
export { createRNG, Mulberry32RNG } from './utils/rng';
|
export { createRNG, Mulberry32RNG } from './utils/rng';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
|
import {createGameCommandRegistry, Part, MutableSignal, createRegion} from '@/index';
|
||||||
|
|
||||||
const BOARD_SIZE = 6;
|
const BOARD_SIZE = 6;
|
||||||
const MAX_PIECES_PER_PLAYER = 8;
|
const MAX_PIECES_PER_PLAYER = 8;
|
||||||
|
|
@ -18,26 +18,15 @@ type Player = {
|
||||||
cat: PieceSupply;
|
cat: PieceSupply;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlayerEntity = Entity<Player>;
|
type PlayerData = Record<PlayerType, Player>;
|
||||||
|
|
||||||
function createPlayer(id: PlayerType): PlayerEntity {
|
|
||||||
return entity<Player>(id, {
|
|
||||||
id,
|
|
||||||
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
|
|
||||||
cat: { supply: 0, placed: 0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: new RegionEntity('board', {
|
board: createRegion('board', [
|
||||||
id: 'board',
|
|
||||||
axes: [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
],
|
]),
|
||||||
children: [],
|
pieces: [] as BoopPart[],
|
||||||
}),
|
|
||||||
currentPlayer: 'white' as PlayerType,
|
currentPlayer: 'white' as PlayerType,
|
||||||
winner: null as WinnerType,
|
winner: null as WinnerType,
|
||||||
players: {
|
players: {
|
||||||
|
|
@ -46,26 +35,30 @@ export function createInitialState() {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPlayer(id: PlayerType): Player {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
|
||||||
|
cat: { supply: 0, placed: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type BoopState = ReturnType<typeof createInitialState>;
|
export type BoopState = ReturnType<typeof createInitialState>;
|
||||||
const registration = createGameCommandRegistry<BoopState>();
|
const registration = createGameCommandRegistry<BoopState>();
|
||||||
export const registry = registration.registry;
|
export const registry = registration.registry;
|
||||||
|
|
||||||
// Player Entity helper functions
|
export function getPlayer(host: MutableSignal<BoopState>, player: PlayerType): Player {
|
||||||
export function getPlayer(host: Entity<BoopState>, player: PlayerType): PlayerEntity {
|
|
||||||
return host.value.players[player];
|
return host.value.players[player];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decrementSupply(player: PlayerEntity, pieceType: PieceType) {
|
export function decrementSupply(player: Player, pieceType: PieceType) {
|
||||||
player.produce(p => {
|
player[pieceType].supply--;
|
||||||
p[pieceType].supply--;
|
player[pieceType].placed++;
|
||||||
p[pieceType].placed++;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function incrementSupply(player: PlayerEntity, pieceType: PieceType, count?: number) {
|
export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
|
||||||
player.produce(p => {
|
player[pieceType].supply += count ?? 1;
|
||||||
p[pieceType].supply += count ?? 1;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registration.add('setup', async function() {
|
registration.add('setup', async function() {
|
||||||
|
|
@ -106,8 +99,8 @@ registration.add('turn <player>', async function(cmd) {
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
return `Cell (${row}, ${col}) is already occupied.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerEntity = getPlayer(this.context, player);
|
const playerData = getPlayer(this.context, player);
|
||||||
const supply = playerEntity.value[pieceType].supply;
|
const supply = playerData[pieceType].supply;
|
||||||
if (supply <= 0) {
|
if (supply <= 0) {
|
||||||
return `No ${pieceType}s left in ${player}'s supply.`;
|
return `No ${pieceType}s left in ${player}'s supply.`;
|
||||||
}
|
}
|
||||||
|
|
@ -126,15 +119,9 @@ registration.add('turn <player>', async function(cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
|
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
|
||||||
const board = getBoardRegion(this.context);
|
const availableKittens = this.context.value.pieces.filter(
|
||||||
const partsMap = board.partsMap.value;
|
p => p.player === turnPlayer && p.pieceType === 'kitten'
|
||||||
const availableKittens: Entity<BoopPart>[] = [];
|
);
|
||||||
for (const key in partsMap) {
|
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
|
||||||
if (part.value.player === turnPlayer && part.value.pieceType === 'kitten') {
|
|
||||||
availableKittens.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableKittens.length > 0) {
|
if (availableKittens.length > 0) {
|
||||||
const graduateCmd = await this.prompt(
|
const graduateCmd = await this.prompt(
|
||||||
|
|
@ -142,16 +129,16 @@ registration.add('turn <player>', async function(cmd) {
|
||||||
(command) => {
|
(command) => {
|
||||||
const [row, col] = command.params as [number, number];
|
const [row, col] = command.params as [number, number];
|
||||||
const posKey = `${row},${col}`;
|
const posKey = `${row},${col}`;
|
||||||
const part = availableKittens.find(p => `${p.value.position[0]},${p.value.position[1]}` === posKey);
|
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
|
||||||
if (!part) return `No kitten at (${row}, ${col}).`;
|
if (!part) return `No kitten at (${row}, ${col}).`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const [row, col] = graduateCmd.params as [number, number];
|
const [row, col] = graduateCmd.params as [number, number];
|
||||||
const part = availableKittens.find(p => p.value.position[0] === row && p.value.position[1] === col)!;
|
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
|
||||||
removePieceFromBoard(this.context, part);
|
removePieceFromBoard(this.context, part);
|
||||||
const playerEntity = getPlayer(this.context, turnPlayer);
|
const playerData = getPlayer(this.context, turnPlayer);
|
||||||
incrementSupply(playerEntity, 'cat', 1);
|
incrementSupply(playerData, 'cat', 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,50 +152,50 @@ function isValidMove(row: number, col: number): boolean {
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBoardRegion(host: Entity<BoopState>) {
|
export function getBoardRegion(host: MutableSignal<BoopState>) {
|
||||||
return host.value.board;
|
return host.value.board;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: Entity<BoopState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
return board.partsMap.value[`${row},${col}`] !== undefined;
|
return board.partMap[`${row},${col}`] !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPartAt(host: Entity<BoopState>, row: number, col: number): Entity<BoopPart> | null {
|
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
return (board.partsMap.value[`${row},${col}`] as Entity<BoopPart> | undefined) || null;
|
const partId = board.partMap[`${row},${col}`];
|
||||||
|
if (!partId) return null;
|
||||||
|
return host.value.pieces.find(p => p.id === partId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const playerEntity = getPlayer(host, player);
|
const playerData = getPlayer(host, player);
|
||||||
const count = playerEntity.value[pieceType].placed + 1;
|
const count = playerData[pieceType].placed + 1;
|
||||||
|
|
||||||
const piece: BoopPart = {
|
const piece: BoopPart = {
|
||||||
id: `${player}-${pieceType}-${count}`,
|
id: `${player}-${pieceType}-${count}`,
|
||||||
region: board,
|
regionId: 'board',
|
||||||
position: [row, col],
|
position: [row, col],
|
||||||
player,
|
player,
|
||||||
pieceType,
|
pieceType,
|
||||||
};
|
};
|
||||||
host.produce(s => {
|
host.produce(s => {
|
||||||
const e = entity(piece.id, piece);
|
s.pieces.push(piece);
|
||||||
board.produce(draft => {
|
board.childIds.push(piece.id);
|
||||||
draft.children.push(e);
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
});
|
});
|
||||||
});
|
decrementSupply(playerData, pieceType);
|
||||||
decrementSupply(playerEntity, pieceType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
|
export function applyBoops(host: MutableSignal<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const partsMap = board.partsMap.value;
|
const pieces = host.value.pieces;
|
||||||
|
|
||||||
const piecesToBoop: { part: Entity<BoopPart>; dr: number; dc: number }[] = [];
|
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
|
||||||
|
|
||||||
for (const key in partsMap) {
|
for (const part of pieces) {
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
const [r, c] = part.position;
|
||||||
const [r, c] = part.value.position;
|
|
||||||
if (r === placedRow && c === placedCol) continue;
|
if (r === placedRow && c === placedCol) continue;
|
||||||
|
|
||||||
const dr = Math.sign(r - placedRow);
|
const dr = Math.sign(r - placedRow);
|
||||||
|
|
@ -216,7 +203,7 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
|
||||||
|
|
||||||
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
|
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
|
||||||
const booperIsKitten = placedType === 'kitten';
|
const booperIsKitten = placedType === 'kitten';
|
||||||
const targetIsCat = part.value.pieceType === 'cat';
|
const targetIsCat = part.pieceType === 'cat';
|
||||||
|
|
||||||
if (booperIsKitten && targetIsCat) continue;
|
if (booperIsKitten && targetIsCat) continue;
|
||||||
|
|
||||||
|
|
@ -225,36 +212,38 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { part, dr, dc } of piecesToBoop) {
|
for (const { part, dr, dc } of piecesToBoop) {
|
||||||
const [r, c] = part.value.position;
|
const [r, c] = part.position;
|
||||||
const newRow = r + dr;
|
const newRow = r + dr;
|
||||||
const newCol = c + dc;
|
const newCol = c + dc;
|
||||||
|
|
||||||
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
|
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
|
||||||
const pt = part.value.pieceType;
|
const pt = part.pieceType;
|
||||||
const pl = part.value.player;
|
const pl = part.player;
|
||||||
const playerEntity = getPlayer(host, pl);
|
const playerData = getPlayer(host, pl);
|
||||||
removePieceFromBoard(host, part);
|
removePieceFromBoard(host, part);
|
||||||
incrementSupply(playerEntity, pt);
|
incrementSupply(playerData, pt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCellOccupied(host, newRow, newCol)) continue;
|
if (isCellOccupied(host, newRow, newCol)) continue;
|
||||||
|
|
||||||
part.produce(p => {
|
part.position = [newRow, newCol];
|
||||||
p.position = [newRow, newCol];
|
board.partMap = Object.fromEntries(
|
||||||
});
|
board.childIds.map(id => {
|
||||||
|
const p = pieces.find(x => x.id === id)!;
|
||||||
|
return [p.position.join(','), id];
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) {
|
export function removePieceFromBoard(host: MutableSignal<BoopState>, part: BoopPart) {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const playerEntity = getPlayer(host, part.value.player);
|
const playerData = getPlayer(host, part.player);
|
||||||
board.produce(draft => {
|
board.childIds = board.childIds.filter(id => id !== part.id);
|
||||||
draft.children = draft.children.filter(p => p.id !== part.id);
|
delete board.partMap[part.position.join(',')];
|
||||||
});
|
host.value.pieces = host.value.pieces.filter(p => p.id !== part.id);
|
||||||
playerEntity.produce(p => {
|
playerData[part.pieceType].placed--;
|
||||||
p[part.value.pieceType].placed--;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECTIONS: [number, number][] = [
|
const DIRECTIONS: [number, number][] = [
|
||||||
|
|
@ -308,15 +297,13 @@ export function hasWinningLine(positions: number[][]): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] {
|
export function checkGraduation(host: MutableSignal<BoopState>, player: PlayerType): number[][][] {
|
||||||
const board = getBoardRegion(host);
|
const pieces = host.value.pieces;
|
||||||
const partsMap = board.partsMap.value;
|
|
||||||
const posSet = new Set<string>();
|
const posSet = new Set<string>();
|
||||||
|
|
||||||
for (const key in partsMap) {
|
for (const part of pieces) {
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
if (part.player === player && part.pieceType === 'kitten') {
|
||||||
if (part.value.player === player && part.value.pieceType === 'kitten') {
|
posSet.add(`${part.position[0]},${part.position[1]}`);
|
||||||
posSet.add(`${part.value.position[0]},${part.value.position[1]}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +316,7 @@ export function checkGraduation(host: Entity<BoopState>, player: PlayerType): nu
|
||||||
return winningLines;
|
return winningLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processGraduation(host: Entity<BoopState>, player: PlayerType, lines: number[][][]) {
|
export function processGraduation(host: MutableSignal<BoopState>, player: PlayerType, lines: number[][][]) {
|
||||||
const allPositions = new Set<string>();
|
const allPositions = new Set<string>();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
for (const [r, c] of line) {
|
for (const [r, c] of line) {
|
||||||
|
|
@ -338,48 +325,31 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
|
||||||
}
|
}
|
||||||
|
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const partsMap = board.partsMap.value;
|
const partsToRemove = host.value.pieces.filter(
|
||||||
const partsToRemove: Entity<BoopPart>[] = [];
|
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
|
||||||
|
);
|
||||||
for (const key in partsMap) {
|
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
|
||||||
if (part.value.player === player && part.value.pieceType === 'kitten' && allPositions.has(`${part.value.position[0]},${part.value.position[1]}`)) {
|
|
||||||
partsToRemove.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const part of partsToRemove) {
|
for (const part of partsToRemove) {
|
||||||
removePieceFromBoard(host, part);
|
removePieceFromBoard(host, part);
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = partsToRemove.length;
|
const count = partsToRemove.length;
|
||||||
const playerEntity = getPlayer(host, player);
|
const playerData = getPlayer(host, player);
|
||||||
incrementSupply(playerEntity, 'cat', count);
|
incrementSupply(playerData, 'cat', count);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countPiecesOnBoard(host: Entity<BoopState>, player: PlayerType): number {
|
export function countPiecesOnBoard(host: MutableSignal<BoopState>, player: PlayerType): number {
|
||||||
const board = getBoardRegion(host);
|
const pieces = host.value.pieces;
|
||||||
const partsMap = board.partsMap.value;
|
return pieces.filter(p => p.player === player).length;
|
||||||
let count = 0;
|
|
||||||
for (const key in partsMap) {
|
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
|
||||||
if (part.value.player === player) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: Entity<BoopState>): WinnerType {
|
export function checkWinner(host: MutableSignal<BoopState>): WinnerType {
|
||||||
const board = getBoardRegion(host);
|
const pieces = host.value.pieces;
|
||||||
const partsMap = board.partsMap.value;
|
|
||||||
|
|
||||||
for (const player of ['white', 'black'] as PlayerType[]) {
|
for (const player of ['white', 'black'] as PlayerType[]) {
|
||||||
const positions: number[][] = [];
|
const positions = pieces
|
||||||
for (const key in partsMap) {
|
.filter(p => p.player === player && p.pieceType === 'cat')
|
||||||
const part = partsMap[key] as Entity<BoopPart>;
|
.map(p => p.position);
|
||||||
if (part.value.player === player && part.value.pieceType === 'cat') {
|
|
||||||
positions.push(part.value.position);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasWinningLine(positions)) return player;
|
if (hasWinningLine(positions)) return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
|
import {createGameCommandRegistry, Part, MutableSignal, createRegion, moveToRegion} from '@/index';
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
|
@ -20,15 +20,11 @@ type TicTacToePart = Part & { player: PlayerType };
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: new RegionEntity('board', {
|
board: createRegion('board', [
|
||||||
id: 'board',
|
|
||||||
axes: [
|
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
],
|
]),
|
||||||
children: [],
|
parts: {} as Record<string, TicTacToePart>,
|
||||||
}),
|
|
||||||
parts: [] as Entity<TicTacToePart>[],
|
|
||||||
currentPlayer: 'X' as PlayerType,
|
currentPlayer: 'X' as PlayerType,
|
||||||
winner: null as WinnerType,
|
winner: null as WinnerType,
|
||||||
turn: 0,
|
turn: 0,
|
||||||
|
|
@ -94,9 +90,9 @@ function isValidMove(row: number, col: number): boolean {
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
||||||
const board = host.value.board;
|
const board = host.value.board;
|
||||||
return board.partsMap.value[`${row},${col}`] !== undefined;
|
return board.partMap[`${row},${col}`] !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
|
|
@ -107,8 +103,8 @@ export function hasWinningLine(positions: number[][]): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
|
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
||||||
const parts = host.value.parts.map((e: Entity<TicTacToePart>) => e.value);
|
const parts = Object.values(host.value.parts);
|
||||||
|
|
||||||
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
||||||
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||||
|
|
@ -120,20 +116,18 @@ export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||||
const board = host.value.board;
|
const board = host.value.board;
|
||||||
const moveNumber = host.value.parts.length + 1;
|
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||||
const piece: TicTacToePart = {
|
const piece: TicTacToePart = {
|
||||||
id: `piece-${player}-${moveNumber}`,
|
id: `piece-${player}-${moveNumber}`,
|
||||||
region: board,
|
regionId: 'board',
|
||||||
position: [row, col],
|
position: [row, col],
|
||||||
player,
|
player,
|
||||||
};
|
};
|
||||||
host.produce(state => {
|
host.produce(state => {
|
||||||
const e = entity(piece.id, piece)
|
state.parts[piece.id] = piece;
|
||||||
state.parts.push(e);
|
board.childIds.push(piece.id);
|
||||||
board.produce(draft => {
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
draft.children.push(e);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext
|
||||||
registry: CommandRegistry<TContext>;
|
registry: CommandRegistry<TContext>;
|
||||||
promptQueue: AsyncQueue<PromptEvent>;
|
promptQueue: AsyncQueue<PromptEvent>;
|
||||||
_activePrompt: PromptEvent | null;
|
_activePrompt: PromptEvent | null;
|
||||||
_tryCommit: (command: Command) => string | null;
|
_tryCommit: (commandOrInput: Command | string) => string | null;
|
||||||
_cancel: (reason?: string) => void;
|
_cancel: (reason?: string) => void;
|
||||||
_pendingInput: string | null;
|
_pendingInput: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -66,9 +66,9 @@ export function createCommandRunnerContext<TContext>(
|
||||||
|
|
||||||
let activePrompt: PromptEvent | null = null;
|
let activePrompt: PromptEvent | null = null;
|
||||||
|
|
||||||
const tryCommit = (command: Command) => {
|
const tryCommit = (commandOrInput: Command | string) => {
|
||||||
if (activePrompt) {
|
if (activePrompt) {
|
||||||
const result = activePrompt.tryCommit(command);
|
const result = activePrompt.tryCommit(commandOrInput);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
activePrompt = null;
|
activePrompt = null;
|
||||||
}
|
}
|
||||||
|
|
@ -90,10 +90,15 @@ export function createCommandRunnerContext<TContext>(
|
||||||
): Promise<Command> => {
|
): Promise<Command> => {
|
||||||
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tryCommit = (command: Command) => {
|
const tryCommit = (commandOrInput: Command | string) => {
|
||||||
const error = validator?.(command);
|
const command = typeof commandOrInput === 'string' ? parseCommand(commandOrInput) : commandOrInput;
|
||||||
|
const schemaResult = applyCommandSchema(command, resolvedSchema);
|
||||||
|
if (!schemaResult.valid) {
|
||||||
|
return schemaResult.errors.join('; ');
|
||||||
|
}
|
||||||
|
const error = validator?.(schemaResult.command);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
resolve(command);
|
resolve(schemaResult.command);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const cancel = (reason?: string) => {
|
const cancel = (reason?: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import type { Command, CommandSchema } from './types';
|
import type { Command, CommandSchema } from './types';
|
||||||
|
import { parseCommand } from './command-parse';
|
||||||
|
import { applyCommandSchema } from './command-validate';
|
||||||
|
|
||||||
export type PromptEvent = {
|
export type PromptEvent = {
|
||||||
schema: CommandSchema;
|
schema: CommandSchema;
|
||||||
/**
|
/**
|
||||||
* 尝试提交命令
|
* 尝试提交命令
|
||||||
|
* @param commandOrInput Command 对象或命令字符串
|
||||||
* @returns null - 验证成功,Promise 已 resolve
|
* @returns null - 验证成功,Promise 已 resolve
|
||||||
* @returns string - 验证失败,返回错误消息,Promise 未 resolve
|
* @returns string - 验证失败,返回错误消息,Promise 未 resolve
|
||||||
*/
|
*/
|
||||||
tryCommit: (command: Command) => string | null;
|
tryCommit: (commandOrInput: Command | string) => string | null;
|
||||||
/** 取消 prompt,Promise 被 reject */
|
/** 取消 prompt,Promise 被 reject */
|
||||||
cancel: (reason?: string) => void;
|
cancel: (reason?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {Signal, signal, SignalOptions} from "@preact/signals-core";
|
import {Signal, signal, SignalOptions} from "@preact/signals-core";
|
||||||
import {create} from 'mutative';
|
import {create} from 'mutative';
|
||||||
|
|
||||||
export class Entity<T> extends Signal<T> {
|
export class MutableSignal<T> extends Signal<T> {
|
||||||
public constructor(public readonly id: string, t?: T, options?: SignalOptions<T>) {
|
public constructor(t?: T, options?: SignalOptions<T>) {
|
||||||
super(t, options);
|
super(t, options);
|
||||||
}
|
}
|
||||||
produce(fn: (draft: T) => void) {
|
produce(fn: (draft: T) => void) {
|
||||||
|
|
@ -10,19 +10,19 @@ export class Entity<T> extends Signal<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function entity<T = undefined>(id: string, t?: T, options?: SignalOptions<T>) {
|
export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> {
|
||||||
return new Entity<T>(id, t, options);
|
return new MutableSignal<T>(initial, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EntityCollection<T> = {
|
export type EntityCollection<T> = {
|
||||||
collection: Signal<Record<string, Entity<T>>>;
|
collection: Signal<Record<string, MutableSignal<T>>>;
|
||||||
remove(...ids: string[]): void;
|
remove(...ids: string[]): void;
|
||||||
add(...entities: (T & {id: string})[]): void;
|
add(...entities: (T & {id: string})[]): void;
|
||||||
get(id: string): Entity<T>;
|
get(id: string): MutableSignal<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEntityCollection<T>(): EntityCollection<T> {
|
export function createEntityCollection<T>(): EntityCollection<T> {
|
||||||
const collection = signal({} as Record<string, Entity<T>>);
|
const collection = signal({} as Record<string, MutableSignal<T>>);
|
||||||
const remove = (...ids: string[]) => {
|
const remove = (...ids: string[]) => {
|
||||||
collection.value = Object.fromEntries(
|
collection.value = Object.fromEntries(
|
||||||
Object.entries(collection.value).filter(([id]) => !ids.includes(id)),
|
Object.entries(collection.value).filter(([id]) => !ids.includes(id)),
|
||||||
|
|
@ -32,7 +32,7 @@ export function createEntityCollection<T>(): EntityCollection<T> {
|
||||||
const add = (...entities: (T & {id: string})[]) => {
|
const add = (...entities: (T & {id: string})[]) => {
|
||||||
collection.value = {
|
collection.value = {
|
||||||
...collection.value,
|
...collection.value,
|
||||||
...Object.fromEntries(entities.map((e) => [e.id, entity(e.id, e)])),
|
...Object.fromEntries(entities.map((e) => [e.id, mutableSignal(e)])),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1,135 +1,138 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region';
|
import { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region';
|
||||||
import { createRNG } from '@/utils/rng';
|
import { createRNG } from '@/utils/rng';
|
||||||
import { entity, Entity } from '@/utils/entity';
|
|
||||||
import { type Part } from '@/core/part';
|
import { type Part } from '@/core/part';
|
||||||
|
|
||||||
describe('Region', () => {
|
describe('Region', () => {
|
||||||
function createTestRegion(axes: RegionAxis[], parts: Part[]): Entity<Region> {
|
function createTestRegion(axes: RegionAxis[], parts: Part[]): { region: Region; parts: Record<string, Part> } {
|
||||||
const partEntities = parts.map(p => entity(p.id, p));
|
const partsMap: Record<string, Part> = {};
|
||||||
return entity('region1', {
|
for (const p of parts) {
|
||||||
id: 'region1',
|
partsMap[p.id] = { ...p };
|
||||||
axes: [...axes],
|
}
|
||||||
children: partEntities,
|
const region = createRegion('region1', axes);
|
||||||
});
|
region.childIds = parts.map(p => p.id);
|
||||||
|
region.partMap = Object.fromEntries(
|
||||||
|
parts.map(p => [p.position.join(','), p.id])
|
||||||
|
);
|
||||||
|
return { region, parts: partsMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('applyAlign', () => {
|
describe('applyAlign', () => {
|
||||||
it('should do nothing with empty region', () => {
|
it('should do nothing with empty region', () => {
|
||||||
const region = createTestRegion([{ name: 'x', min: 0, align: 'start' }], []);
|
const { region } = createTestRegion([{ name: 'x', min: 0, align: 'start' }], []);
|
||||||
applyAlign(region);
|
applyAlign(region, {});
|
||||||
expect(region.value.children).toHaveLength(0);
|
expect(region.childIds).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align parts to start on first axis', () => {
|
it('should align parts to start on first axis', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [5, 10] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [5, 10] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [7, 20] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [7, 20] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [2, 30] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', min: 0, align: 'start' }, { name: 'y' }],
|
[{ name: 'x', min: 0, align: 'start' }, { name: 'y' }],
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.position[0]).toBe(0);
|
expect(parts[region.childIds[0]].position[0]).toBe(0);
|
||||||
expect(region.value.children[1].value.position[0]).toBe(1);
|
expect(parts[region.childIds[1]].position[0]).toBe(1);
|
||||||
expect(region.value.children[2].value.position[0]).toBe(2);
|
expect(parts[region.childIds[2]].position[0]).toBe(2);
|
||||||
expect(region.value.children[0].value.position[1]).toBe(30);
|
expect(parts[region.childIds[0]].position[1]).toBe(30);
|
||||||
expect(region.value.children[1].value.position[1]).toBe(10);
|
expect(parts[region.childIds[1]].position[1]).toBe(10);
|
||||||
expect(region.value.children[2].value.position[1]).toBe(20);
|
expect(parts[region.childIds[2]].position[1]).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align parts to start with custom min', () => {
|
it('should align parts to start with custom min', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [5, 100] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [5, 100] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [7, 200] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [7, 200] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', min: 10, align: 'start' }, { name: 'y' }],
|
[{ name: 'x', min: 10, align: 'start' }, { name: 'y' }],
|
||||||
[part1, part2]
|
[part1, part2]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.position[0]).toBe(10);
|
expect(parts[region.childIds[0]].position[0]).toBe(10);
|
||||||
expect(region.value.children[1].value.position[0]).toBe(11);
|
expect(parts[region.childIds[1]].position[0]).toBe(11);
|
||||||
expect(region.value.children[0].value.position[1]).toBe(100);
|
expect(parts[region.childIds[0]].position[1]).toBe(100);
|
||||||
expect(region.value.children[1].value.position[1]).toBe(200);
|
expect(parts[region.childIds[1]].position[1]).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align parts to end on first axis', () => {
|
it('should align parts to end on first axis', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [2, 50] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [2, 50] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [4, 60] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [4, 60] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [1, 70] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [1, 70] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', max: 10, align: 'end' }, { name: 'y' }],
|
[{ name: 'x', max: 10, align: 'end' }, { name: 'y' }],
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.position[0]).toBe(8);
|
expect(parts[region.childIds[0]].position[0]).toBe(8);
|
||||||
expect(region.value.children[1].value.position[0]).toBe(9);
|
expect(parts[region.childIds[1]].position[0]).toBe(9);
|
||||||
expect(region.value.children[2].value.position[0]).toBe(10);
|
expect(parts[region.childIds[2]].position[0]).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align parts to center on first axis', () => {
|
it('should align parts to center on first axis', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 5] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [0, 5] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 6] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [1, 6] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 7] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [2, 7] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', min: 0, max: 10, align: 'center' }, { name: 'y' }],
|
[{ name: 'x', min: 0, max: 10, align: 'center' }, { name: 'y' }],
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.position[0]).toBe(4);
|
expect(parts[region.childIds[0]].position[0]).toBe(4);
|
||||||
expect(region.value.children[1].value.position[0]).toBe(5);
|
expect(parts[region.childIds[1]].position[0]).toBe(5);
|
||||||
expect(region.value.children[2].value.position[0]).toBe(6);
|
expect(parts[region.childIds[2]].position[0]).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle even count center alignment', () => {
|
it('should handle even count center alignment', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [0, 10] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [1, 20] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', min: 0, max: 10, align: 'center' }, { name: 'y' }],
|
[{ name: 'x', min: 0, max: 10, align: 'center' }, { name: 'y' }],
|
||||||
[part1, part2]
|
[part1, part2]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.position[0]).toBe(4.5);
|
expect(parts[region.childIds[0]].position[0]).toBe(4.5);
|
||||||
expect(region.value.children[1].value.position[0]).toBe(5.5);
|
expect(parts[region.childIds[1]].position[0]).toBe(5.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sort children by position on current axis', () => {
|
it('should sort children by position on current axis', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [5, 100] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [5, 100] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 200] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [1, 200] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [3, 300] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [3, 300] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[{ name: 'x', min: 0, align: 'start' }, { name: 'y' }],
|
[{ name: 'x', min: 0, align: 'start' }, { name: 'y' }],
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
expect(region.value.children[0].value.id).toBe('p2');
|
expect(region.childIds[0]).toBe('p2');
|
||||||
expect(region.value.children[1].value.id).toBe('p3');
|
expect(region.childIds[1]).toBe('p3');
|
||||||
expect(region.value.children[2].value.id).toBe('p1');
|
expect(region.childIds[2]).toBe('p1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align on multiple axes', () => {
|
it('should align on multiple axes', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [5, 10] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [5, 10] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [7, 20] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [7, 20] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [2, 30] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[
|
[
|
||||||
{ name: 'x', min: 0, align: 'start' },
|
{ name: 'x', min: 0, align: 'start' },
|
||||||
{ name: 'y', min: 0, align: 'start' }
|
{ name: 'y', min: 0, align: 'start' }
|
||||||
|
|
@ -137,30 +140,25 @@ describe('Region', () => {
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
const positions = region.value.children.map(c => ({
|
expect(region.childIds[0]).toBe('p3');
|
||||||
id: c.value.id,
|
expect(parts[region.childIds[0]].position).toEqual([0, 2]);
|
||||||
position: c.value.position
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(positions[0].id).toBe('p3');
|
expect(region.childIds[1]).toBe('p1');
|
||||||
expect(positions[0].position).toEqual([0, 2]);
|
expect(parts[region.childIds[1]].position).toEqual([1, 0]);
|
||||||
|
|
||||||
expect(positions[1].id).toBe('p1');
|
expect(region.childIds[2]).toBe('p2');
|
||||||
expect(positions[1].position).toEqual([1, 0]);
|
expect(parts[region.childIds[2]].position).toEqual([2, 1]);
|
||||||
|
|
||||||
expect(positions[2].id).toBe('p2');
|
|
||||||
expect(positions[2].position).toEqual([2, 1]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should align 4 elements on rectangle corners', () => {
|
it('should align 4 elements on rectangle corners', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 0] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [0, 0] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [10, 0] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [10, 0] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [10, 1] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [10, 1] };
|
||||||
const part4: Part = { id: 'p4', region: null as any, position: [0, 1] };
|
const part4: Part = { id: 'p4', regionId: 'region1', position: [0, 1] };
|
||||||
|
|
||||||
const region = createTestRegion(
|
const { region, parts } = createTestRegion(
|
||||||
[
|
[
|
||||||
{ name: 'x', min: 0, max: 10, align: 'start' },
|
{ name: 'x', min: 0, max: 10, align: 'start' },
|
||||||
{ name: 'y', min: 0, max: 10, align: 'start' }
|
{ name: 'y', min: 0, max: 10, align: 'start' }
|
||||||
|
|
@ -168,55 +166,50 @@ describe('Region', () => {
|
||||||
[part1, part2, part3, part4]
|
[part1, part2, part3, part4]
|
||||||
);
|
);
|
||||||
|
|
||||||
applyAlign(region);
|
applyAlign(region, parts);
|
||||||
|
|
||||||
const positions = region.value.children.map(c => ({
|
expect(region.childIds[0]).toBe('p1');
|
||||||
id: c.value.id,
|
expect(parts[region.childIds[0]].position).toEqual([0, 0]);
|
||||||
position: c.value.position
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(positions[0].id).toBe('p1');
|
expect(region.childIds[1]).toBe('p4');
|
||||||
expect(positions[0].position).toEqual([0, 0]);
|
expect(parts[region.childIds[1]].position).toEqual([0, 1]);
|
||||||
|
|
||||||
expect(positions[1].id).toBe('p4');
|
expect(region.childIds[2]).toBe('p2');
|
||||||
expect(positions[1].position).toEqual([0, 1]);
|
expect(parts[region.childIds[2]].position).toEqual([1, 0]);
|
||||||
|
|
||||||
expect(positions[2].id).toBe('p2');
|
expect(region.childIds[3]).toBe('p3');
|
||||||
expect(positions[2].position).toEqual([1, 0]);
|
expect(parts[region.childIds[3]].position).toEqual([1, 1]);
|
||||||
|
|
||||||
expect(positions[3].id).toBe('p3');
|
|
||||||
expect(positions[3].position).toEqual([1, 1]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shuffle', () => {
|
describe('shuffle', () => {
|
||||||
it('should do nothing with empty region', () => {
|
it('should do nothing with empty region', () => {
|
||||||
const region = createTestRegion([], []);
|
const { region, parts } = createTestRegion([], []);
|
||||||
const rng = createRNG(42);
|
const rng = createRNG(42);
|
||||||
shuffle(region, rng);
|
shuffle(region, parts, rng);
|
||||||
expect(region.value.children).toHaveLength(0);
|
expect(region.childIds).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing with single part', () => {
|
it('should do nothing with single part', () => {
|
||||||
const part: Part = { id: 'p1', region: null as any, position: [0, 0, 0] };
|
const part: Part = { id: 'p1', regionId: 'region1', position: [0, 0, 0] };
|
||||||
const region = createTestRegion([], [part]);
|
const { region, parts } = createTestRegion([], [part]);
|
||||||
const rng = createRNG(42);
|
const rng = createRNG(42);
|
||||||
shuffle(region, rng);
|
shuffle(region, parts, rng);
|
||||||
expect(region.value.children[0].value.position).toEqual([0, 0, 0]);
|
expect(parts['p1'].position).toEqual([0, 0, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should shuffle positions of multiple parts', () => {
|
it('should shuffle positions of multiple parts', () => {
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 100] };
|
const part1: Part = { id: 'p1', regionId: 'region1', position: [0, 100] };
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 200] };
|
const part2: Part = { id: 'p2', regionId: 'region1', position: [1, 200] };
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 300] };
|
const part3: Part = { id: 'p3', regionId: 'region1', position: [2, 300] };
|
||||||
|
|
||||||
const region = createTestRegion([], [part1, part2, part3]);
|
const { region, parts } = createTestRegion([], [part1, part2, part3]);
|
||||||
const rng = createRNG(42);
|
const rng = createRNG(42);
|
||||||
|
|
||||||
const originalPositions = region.value.children.map(c => [...c.value.position]);
|
const originalPositions = region.childIds.map(id => [...parts[id].position]);
|
||||||
shuffle(region, rng);
|
shuffle(region, parts, rng);
|
||||||
|
|
||||||
const newPositions = region.value.children.map(c => c.value.position);
|
const newPositions = region.childIds.map(id => parts[id].position);
|
||||||
|
|
||||||
originalPositions.forEach(origPos => {
|
originalPositions.forEach(origPos => {
|
||||||
const found = newPositions.some(newPos =>
|
const found = newPositions.some(newPos =>
|
||||||
|
|
@ -227,46 +220,44 @@ describe('Region', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be deterministic with same seed', () => {
|
it('should be deterministic with same seed', () => {
|
||||||
const createRegionForTest = () => {
|
const createPartsForTest = (): Part[] => [
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
|
{ id: 'p1', regionId: 'region1', position: [0, 10] },
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
|
{ id: 'p2', regionId: 'region1', position: [1, 20] },
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
|
{ id: 'p3', regionId: 'region1', position: [2, 30] },
|
||||||
return createTestRegion([], [part1, part2, part3]);
|
];
|
||||||
};
|
|
||||||
|
|
||||||
const setup1 = createRegionForTest();
|
const setup1 = createTestRegion([], createPartsForTest());
|
||||||
const setup2 = createRegionForTest();
|
const setup2 = createTestRegion([], createPartsForTest());
|
||||||
|
|
||||||
const rng1 = createRNG(42);
|
const rng1 = createRNG(42);
|
||||||
const rng2 = createRNG(42);
|
const rng2 = createRNG(42);
|
||||||
|
|
||||||
shuffle(setup1, rng1);
|
shuffle(setup1.region, setup1.parts, rng1);
|
||||||
shuffle(setup2, rng2);
|
shuffle(setup2.region, setup2.parts, rng2);
|
||||||
|
|
||||||
const positions1 = setup1.value.children.map(c => c.value.position);
|
const positions1 = setup1.region.childIds.map(id => setup1.parts[id].position);
|
||||||
const positions2 = setup2.value.children.map(c => c.value.position);
|
const positions2 = setup2.region.childIds.map(id => setup2.parts[id].position);
|
||||||
|
|
||||||
expect(positions1).toEqual(positions2);
|
expect(positions1).toEqual(positions2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce different results with different seeds', () => {
|
it('should produce different results with different seeds', () => {
|
||||||
const createRegionForTest = () => {
|
const createPartsForTest = (): Part[] => [
|
||||||
const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
|
{ id: 'p1', regionId: 'region1', position: [0, 10] },
|
||||||
const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
|
{ id: 'p2', regionId: 'region1', position: [1, 20] },
|
||||||
const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
|
{ id: 'p3', regionId: 'region1', position: [2, 30] },
|
||||||
const part4: Part = { id: 'p4', region: null as any, position: [3, 40] };
|
{ id: 'p4', regionId: 'region1', position: [3, 40] },
|
||||||
const part5: Part = { id: 'p5', region: null as any, position: [4, 50] };
|
{ id: 'p5', regionId: 'region1', position: [4, 50] },
|
||||||
return createTestRegion([], [part1, part2, part3, part4, part5]);
|
];
|
||||||
};
|
|
||||||
|
|
||||||
const results = new Set<string>();
|
const results = new Set<string>();
|
||||||
|
|
||||||
for (let seed = 1; seed <= 10; seed++) {
|
for (let seed = 1; seed <= 10; seed++) {
|
||||||
const setup = createRegionForTest();
|
const setup = createTestRegion([], createPartsForTest());
|
||||||
const rng = createRNG(seed);
|
const rng = createRNG(seed);
|
||||||
shuffle(setup, rng);
|
shuffle(setup.region, setup.parts, rng);
|
||||||
|
|
||||||
const positions = JSON.stringify(setup.value.children.map(c => c.value.position));
|
const positions = JSON.stringify(setup.region.childIds.map(id => setup.parts[id].position));
|
||||||
results.add(positions);
|
results.add(positions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,104 +269,110 @@ describe('Region', () => {
|
||||||
it('should move a part from one region to another', () => {
|
it('should move a part from one region to another', () => {
|
||||||
const sourceAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
|
const sourceAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
|
||||||
const targetAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
|
const targetAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
|
||||||
const sourceRegion = createTestRegion(sourceAxes, []);
|
const sourceRegion = createRegion('source', sourceAxes);
|
||||||
const targetRegion = createTestRegion(targetAxes, []);
|
const targetRegion = createRegion('target', targetAxes);
|
||||||
|
|
||||||
const part: Part = { id: 'p1', region: sourceRegion, position: [2] };
|
const part: Part = { id: 'p1', regionId: 'source', position: [2] };
|
||||||
const partEntity = entity(part.id, part);
|
const parts: Record<string, Part> = { p1: part };
|
||||||
sourceRegion.value.children.push(partEntity);
|
sourceRegion.childIds.push('p1');
|
||||||
|
sourceRegion.partMap['2'] = 'p1';
|
||||||
|
|
||||||
expect(sourceRegion.value.children).toHaveLength(1);
|
expect(sourceRegion.childIds).toHaveLength(1);
|
||||||
expect(targetRegion.value.children).toHaveLength(0);
|
expect(targetRegion.childIds).toHaveLength(0);
|
||||||
expect(partEntity.value.region.value.id).toBe('region1');
|
expect(part.regionId).toBe('source');
|
||||||
|
|
||||||
moveToRegion(partEntity, targetRegion, [0]);
|
moveToRegion(part, sourceRegion, targetRegion, [0]);
|
||||||
|
|
||||||
expect(sourceRegion.value.children).toHaveLength(0);
|
expect(sourceRegion.childIds).toHaveLength(0);
|
||||||
expect(targetRegion.value.children).toHaveLength(1);
|
expect(targetRegion.childIds).toHaveLength(1);
|
||||||
expect(partEntity.value.region.value.id).toBe('region1');
|
expect(part.regionId).toBe('target');
|
||||||
expect(partEntity.value.position).toEqual([0]);
|
expect(part.position).toEqual([0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should keep existing position if no position provided', () => {
|
it('should keep existing position if no position provided', () => {
|
||||||
const sourceRegion = createTestRegion([{ name: 'x' }], []);
|
const sourceRegion = createRegion('source', [{ name: 'x' }]);
|
||||||
const targetRegion = createTestRegion([{ name: 'x' }], []);
|
const targetRegion = createRegion('target', [{ name: 'x' }]);
|
||||||
|
|
||||||
const part: Part = { id: 'p1', region: sourceRegion, position: [3] };
|
const part: Part = { id: 'p1', regionId: 'source', position: [3] };
|
||||||
const partEntity = entity(part.id, part);
|
const parts: Record<string, Part> = { p1: part };
|
||||||
sourceRegion.value.children.push(partEntity);
|
sourceRegion.childIds.push('p1');
|
||||||
|
sourceRegion.partMap['3'] = 'p1';
|
||||||
|
|
||||||
moveToRegion(partEntity, targetRegion);
|
moveToRegion(part, sourceRegion, targetRegion);
|
||||||
|
|
||||||
expect(partEntity.value.position).toEqual([3]);
|
expect(part.position).toEqual([3]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('moveToRegionAll', () => {
|
describe('moveToRegionAll', () => {
|
||||||
it('should move multiple parts to a target region', () => {
|
it('should move multiple parts to a target region', () => {
|
||||||
const sourceRegion = createTestRegion([{ name: 'x' }], []);
|
const sourceRegion = createRegion('source', [{ name: 'x' }]);
|
||||||
const targetRegion = createTestRegion([{ name: 'x' }], []);
|
const targetRegion = createRegion('target', [{ name: 'x' }]);
|
||||||
|
|
||||||
const parts = [
|
const parts = {
|
||||||
entity('p1', { id: 'p1', region: sourceRegion, position: [0] } as Part),
|
p1: { id: 'p1', regionId: 'source', position: [0] } as Part,
|
||||||
entity('p2', { id: 'p2', region: sourceRegion, position: [1] } as Part),
|
p2: { id: 'p2', regionId: 'source', position: [1] } as Part,
|
||||||
entity('p3', { id: 'p3', region: sourceRegion, position: [2] } as Part),
|
p3: { id: 'p3', regionId: 'source', position: [2] } as Part,
|
||||||
];
|
};
|
||||||
sourceRegion.value.children.push(...parts);
|
sourceRegion.childIds.push('p1', 'p2', 'p3');
|
||||||
|
sourceRegion.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' };
|
||||||
|
|
||||||
moveToRegionAll(parts, targetRegion, [[0], [1], [2]]);
|
moveToRegionAll([parts.p1, parts.p2, parts.p3], sourceRegion, targetRegion, [[0], [1], [2]]);
|
||||||
|
|
||||||
expect(sourceRegion.value.children).toHaveLength(0);
|
expect(sourceRegion.childIds).toHaveLength(0);
|
||||||
expect(targetRegion.value.children).toHaveLength(3);
|
expect(targetRegion.childIds).toHaveLength(3);
|
||||||
expect(parts[0].value.position).toEqual([0]);
|
expect(parts.p1.position).toEqual([0]);
|
||||||
expect(parts[1].value.position).toEqual([1]);
|
expect(parts.p2.position).toEqual([1]);
|
||||||
expect(parts[2].value.position).toEqual([2]);
|
expect(parts.p3.position).toEqual([2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should keep existing positions if no positions provided', () => {
|
it('should keep existing positions if no positions provided', () => {
|
||||||
const sourceRegion = createTestRegion([{ name: 'x' }], []);
|
const sourceRegion = createRegion('source', [{ name: 'x' }]);
|
||||||
const targetRegion = createTestRegion([{ name: 'x' }], []);
|
const targetRegion = createRegion('target', [{ name: 'x' }]);
|
||||||
|
|
||||||
const parts = [
|
const parts = {
|
||||||
entity('p1', { id: 'p1', region: sourceRegion, position: [5] } as Part),
|
p1: { id: 'p1', regionId: 'source', position: [5] } as Part,
|
||||||
entity('p2', { id: 'p2', region: sourceRegion, position: [8] } as Part),
|
p2: { id: 'p2', regionId: 'source', position: [8] } as Part,
|
||||||
];
|
};
|
||||||
sourceRegion.value.children.push(...parts);
|
sourceRegion.childIds.push('p1', 'p2');
|
||||||
|
sourceRegion.partMap = { '5': 'p1', '8': 'p2' };
|
||||||
|
|
||||||
moveToRegionAll(parts, targetRegion);
|
moveToRegionAll([parts.p1, parts.p2], sourceRegion, targetRegion);
|
||||||
|
|
||||||
expect(parts[0].value.position).toEqual([5]);
|
expect(parts.p1.position).toEqual([5]);
|
||||||
expect(parts[1].value.position).toEqual([8]);
|
expect(parts.p2.position).toEqual([8]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeFromRegion', () => {
|
describe('removeFromRegion', () => {
|
||||||
it('should remove a part from its region', () => {
|
it('should remove a part from its region', () => {
|
||||||
const region = createTestRegion([{ name: 'x' }], []);
|
const region = createRegion('region1', [{ name: 'x' }]);
|
||||||
|
|
||||||
const part: Part = { id: 'p1', region: region, position: [2] };
|
const part: Part = { id: 'p1', regionId: 'region1', position: [2] };
|
||||||
const partEntity = entity(part.id, part);
|
const parts: Record<string, Part> = { p1: part };
|
||||||
region.value.children.push(partEntity);
|
region.childIds.push('p1');
|
||||||
|
region.partMap['2'] = 'p1';
|
||||||
|
|
||||||
expect(region.value.children).toHaveLength(1);
|
expect(region.childIds).toHaveLength(1);
|
||||||
|
|
||||||
removeFromRegion(partEntity);
|
removeFromRegion(part, region);
|
||||||
|
|
||||||
expect(region.value.children).toHaveLength(0);
|
expect(region.childIds).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should leave other parts unaffected', () => {
|
it('should leave other parts unaffected', () => {
|
||||||
const region = createTestRegion([{ name: 'x' }], []);
|
const region = createRegion('region1', [{ name: 'x' }]);
|
||||||
|
|
||||||
const p1 = entity('p1', { id: 'p1', region: region, position: [0] } as Part);
|
const p1 = { id: 'p1', regionId: 'region1', position: [0] } as Part;
|
||||||
const p2 = entity('p2', { id: 'p2', region: region, position: [1] } as Part);
|
const p2 = { id: 'p2', regionId: 'region1', position: [1] } as Part;
|
||||||
const p3 = entity('p3', { id: 'p3', region: region, position: [2] } as Part);
|
const p3 = { id: 'p3', regionId: 'region1', position: [2] } as Part;
|
||||||
region.value.children.push(p1, p2, p3);
|
region.childIds.push('p1', 'p2', 'p3');
|
||||||
|
region.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' };
|
||||||
|
|
||||||
removeFromRegion(p2);
|
removeFromRegion(p2, region);
|
||||||
|
|
||||||
expect(region.value.children).toHaveLength(2);
|
expect(region.childIds).toHaveLength(2);
|
||||||
expect(region.value.children.map(c => c.value.id)).toEqual(['p1', 'p3']);
|
expect(region.childIds).toEqual(['p1', 'p3']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
PlayerType,
|
PlayerType,
|
||||||
getBoardRegion,
|
getBoardRegion,
|
||||||
} from '@/samples/boop';
|
} from '@/samples/boop';
|
||||||
import {Entity} from "@/utils/entity";
|
import {MutableSignal} from "@/utils/entity";
|
||||||
import {createGameContext} from "@/";
|
import {createGameContext} from "@/";
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent } from '@/utils/command';
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ function createTestContext() {
|
||||||
return { registry, ctx };
|
return { registry, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<BoopState> {
|
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<BoopState> {
|
||||||
return ctx.state;
|
return ctx.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +35,8 @@ function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promis
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParts(state: Entity<BoopState>) {
|
function getParts(state: MutableSignal<BoopState>) {
|
||||||
return state.value.board.value.children;
|
return state.value.pieces;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Boop - helper functions', () => {
|
describe('Boop - helper functions', () => {
|
||||||
|
|
@ -81,8 +81,8 @@ describe('Boop - helper functions', () => {
|
||||||
const part = getPartAt(state, 2, 2);
|
const part = getPartAt(state, 2, 2);
|
||||||
expect(part).not.toBeNull();
|
expect(part).not.toBeNull();
|
||||||
if (part) {
|
if (part) {
|
||||||
expect(part.value.player).toBe('black');
|
expect(part.player).toBe('black');
|
||||||
expect(part.value.pieceType).toBe('kitten');
|
expect(part.pieceType).toBe('kitten');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -95,9 +95,9 @@ describe('Boop - helper functions', () => {
|
||||||
|
|
||||||
const parts = getParts(state);
|
const parts = getParts(state);
|
||||||
expect(parts.length).toBe(1);
|
expect(parts.length).toBe(1);
|
||||||
expect(parts[0].value.position).toEqual([2, 3]);
|
expect(parts[0].position).toEqual([2, 3]);
|
||||||
expect(parts[0].value.player).toBe('white');
|
expect(parts[0].player).toBe('white');
|
||||||
expect(parts[0].value.pieceType).toBe('kitten');
|
expect(parts[0].pieceType).toBe('kitten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should name piece white-kitten-1', () => {
|
it('should name piece white-kitten-1', () => {
|
||||||
|
|
@ -130,23 +130,23 @@ describe('Boop - helper functions', () => {
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'kitten');
|
placePiece(state, 0, 0, 'white', 'kitten');
|
||||||
expect(state.value.players.white.value.kitten.supply).toBe(7);
|
expect(state.value.players.white.kitten.supply).toBe(7);
|
||||||
expect(state.value.players.black.value.kitten.supply).toBe(8);
|
expect(state.value.players.black.kitten.supply).toBe(8);
|
||||||
|
|
||||||
placePiece(state, 0, 1, 'black', 'kitten');
|
placePiece(state, 0, 1, 'black', 'kitten');
|
||||||
expect(state.value.players.white.value.kitten.supply).toBe(7);
|
expect(state.value.players.white.kitten.supply).toBe(7);
|
||||||
expect(state.value.players.black.value.kitten.supply).toBe(7);
|
expect(state.value.players.black.kitten.supply).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decrement the correct player cat supply', () => {
|
it('should decrement the correct player cat supply', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
state.produce(s => {
|
state.produce(s => {
|
||||||
s.players.white.value.cat.supply = 3;
|
s.players.white.cat.supply = 3;
|
||||||
});
|
});
|
||||||
|
|
||||||
placePiece(state, 0, 0, 'white', 'cat');
|
placePiece(state, 0, 0, 'white', 'cat');
|
||||||
expect(state.value.players.white.value.cat.supply).toBe(2);
|
expect(state.value.players.white.cat.supply).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
it('should add piece to board region children', () => {
|
||||||
|
|
@ -155,7 +155,7 @@ describe('Boop - helper functions', () => {
|
||||||
placePiece(state, 1, 1, 'white', 'kitten');
|
placePiece(state, 1, 1, 'white', 'kitten');
|
||||||
|
|
||||||
const board = getBoardRegion(state);
|
const board = getBoardRegion(state);
|
||||||
expect(board.value.children.length).toBe(1);
|
expect(board.childIds.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique IDs for pieces', () => {
|
it('should generate unique IDs for pieces', () => {
|
||||||
|
|
@ -178,11 +178,11 @@ describe('Boop - helper functions', () => {
|
||||||
placePiece(state, 2, 2, 'white', 'kitten');
|
placePiece(state, 2, 2, 'white', 'kitten');
|
||||||
|
|
||||||
const whitePart = getParts(state)[1];
|
const whitePart = getParts(state)[1];
|
||||||
expect(whitePart.value.position).toEqual([2, 2]);
|
expect(whitePart.position).toEqual([2, 2]);
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
applyBoops(state, 3, 3, 'kitten');
|
||||||
|
|
||||||
expect(whitePart.value.position).toEqual([1, 1]);
|
expect(whitePart.position).toEqual([1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not boop a cat when a kitten is placed', () => {
|
it('should not boop a cat when a kitten is placed', () => {
|
||||||
|
|
@ -191,13 +191,11 @@ describe('Boop - helper functions', () => {
|
||||||
|
|
||||||
placePiece(state, 3, 3, 'black', 'kitten');
|
placePiece(state, 3, 3, 'black', 'kitten');
|
||||||
const whitePart = getParts(state)[0];
|
const whitePart = getParts(state)[0];
|
||||||
whitePart.produce(p => {
|
whitePart.pieceType = 'cat';
|
||||||
p.pieceType = 'cat';
|
|
||||||
});
|
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
applyBoops(state, 3, 3, 'kitten');
|
||||||
|
|
||||||
expect(whitePart.value.position).toEqual([3, 3]);
|
expect(whitePart.position).toEqual([3, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove piece that is booped off the board', () => {
|
it('should remove piece that is booped off the board', () => {
|
||||||
|
|
@ -210,8 +208,8 @@ describe('Boop - helper functions', () => {
|
||||||
applyBoops(state, 1, 1, 'kitten');
|
applyBoops(state, 1, 1, 'kitten');
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
expect(getParts(state).length).toBe(1);
|
||||||
expect(getParts(state)[0].value.player).toBe('black');
|
expect(getParts(state)[0].player).toBe('black');
|
||||||
expect(state.value.players.white.value.kitten.supply).toBe(8);
|
expect(state.value.players.white.kitten.supply).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not boop piece if target cell is occupied', () => {
|
it('should not boop piece if target cell is occupied', () => {
|
||||||
|
|
@ -224,10 +222,10 @@ describe('Boop - helper functions', () => {
|
||||||
|
|
||||||
applyBoops(state, 0, 1, 'kitten');
|
applyBoops(state, 0, 1, 'kitten');
|
||||||
|
|
||||||
const whitePart = getParts(state).find(p => p.value.player === 'white');
|
const whitePart = getParts(state).find(p => p.player === 'white');
|
||||||
expect(whitePart).toBeDefined();
|
expect(whitePart).toBeDefined();
|
||||||
if (whitePart) {
|
if (whitePart) {
|
||||||
expect(whitePart.value.position).toEqual([1, 1]);
|
expect(whitePart.position).toEqual([1, 1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -241,8 +239,8 @@ describe('Boop - helper functions', () => {
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
applyBoops(state, 3, 3, 'kitten');
|
||||||
|
|
||||||
expect(getParts(state)[1].value.position).toEqual([1, 1]);
|
expect(getParts(state)[1].position).toEqual([1, 1]);
|
||||||
expect(getParts(state)[2].value.position).toEqual([1, 3]);
|
expect(getParts(state)[2].position).toEqual([1, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not boop the placed piece itself', () => {
|
it('should not boop the placed piece itself', () => {
|
||||||
|
|
@ -253,7 +251,7 @@ describe('Boop - helper functions', () => {
|
||||||
|
|
||||||
applyBoops(state, 3, 3, 'kitten');
|
applyBoops(state, 3, 3, 'kitten');
|
||||||
|
|
||||||
expect(getParts(state)[0].value.position).toEqual([3, 3]);
|
expect(getParts(state)[0].position).toEqual([3, 3]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -267,7 +265,7 @@ describe('Boop - helper functions', () => {
|
||||||
removePieceFromBoard(state, part);
|
removePieceFromBoard(state, part);
|
||||||
|
|
||||||
const board = getBoardRegion(state);
|
const board = getBoardRegion(state);
|
||||||
expect(board.value.children.length).toBe(0);
|
expect(board.childIds.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -343,9 +341,7 @@ describe('Boop - helper functions', () => {
|
||||||
placePiece(state, 0, 1, 'white', 'kitten');
|
placePiece(state, 0, 1, 'white', 'kitten');
|
||||||
placePiece(state, 0, 2, 'white', 'kitten');
|
placePiece(state, 0, 2, 'white', 'kitten');
|
||||||
|
|
||||||
getParts(state)[1].produce(p => {
|
getParts(state)[1].pieceType = 'cat';
|
||||||
p.pieceType = 'cat';
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = checkGraduation(state, 'white');
|
const lines = checkGraduation(state, 'white');
|
||||||
expect(lines.length).toBe(0);
|
expect(lines.length).toBe(0);
|
||||||
|
|
@ -367,7 +363,7 @@ describe('Boop - helper functions', () => {
|
||||||
processGraduation(state, 'white', lines);
|
processGraduation(state, 'white', lines);
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
expect(getParts(state).length).toBe(0);
|
||||||
expect(state.value.players.white.value.cat.supply).toBe(3);
|
expect(state.value.players.white.cat.supply).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only graduate pieces on the winning lines', () => {
|
it('should only graduate pieces on the winning lines', () => {
|
||||||
|
|
@ -383,8 +379,8 @@ describe('Boop - helper functions', () => {
|
||||||
processGraduation(state, 'white', lines);
|
processGraduation(state, 'white', lines);
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(1);
|
expect(getParts(state).length).toBe(1);
|
||||||
expect(getParts(state)[0].value.position).toEqual([3, 3]);
|
expect(getParts(state)[0].position).toEqual([3, 3]);
|
||||||
expect(state.value.players.white.value.cat.supply).toBe(3);
|
expect(state.value.players.white.cat.supply).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -487,7 +483,7 @@ describe('Boop - game flow', () => {
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
expect(getParts(ctx.state).length).toBe(1);
|
expect(getParts(ctx.state).length).toBe(1);
|
||||||
expect(getParts(ctx.state)[0].value.position).toEqual([2, 2]);
|
expect(getParts(ctx.state)[0].position).toEqual([2, 2]);
|
||||||
expect(getParts(ctx.state)[0].id).toBe('white-kitten-1');
|
expect(getParts(ctx.state)[0].id).toBe('white-kitten-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -537,7 +533,9 @@ describe('Boop - game flow', () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
|
|
||||||
state.value.players.white.value.kitten.supply = 0;
|
state.produce(s => {
|
||||||
|
s.players.white.kitten.supply = 0;
|
||||||
|
});
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
||||||
|
|
@ -575,10 +573,10 @@ describe('Boop - game flow', () => {
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(getParts(state).length).toBe(2);
|
expect(getParts(state).length).toBe(2);
|
||||||
|
|
||||||
const whitePart = getParts(state).find(p => p.value.player === 'white');
|
const whitePart = getParts(state).find(p => p.player === 'white');
|
||||||
expect(whitePart).toBeDefined();
|
expect(whitePart).toBeDefined();
|
||||||
if (whitePart) {
|
if (whitePart) {
|
||||||
expect(whitePart.value.position).not.toEqual([3, 3]);
|
expect(whitePart.position).not.toEqual([3, 3]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -596,14 +594,16 @@ describe('Boop - game flow', () => {
|
||||||
processGraduation(state, 'white', lines);
|
processGraduation(state, 'white', lines);
|
||||||
|
|
||||||
expect(getParts(state).length).toBe(0);
|
expect(getParts(state).length).toBe(0);
|
||||||
expect(state.value.players.white.value.cat.supply).toBe(3);
|
expect(state.value.players.white.cat.supply).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept placing a cat via play command', async () => {
|
it('should accept placing a cat via play command', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
|
|
||||||
state.value.players.white.value.cat.supply = 3;
|
state.produce(s => {
|
||||||
|
s.players.white.cat.supply = 3;
|
||||||
|
});
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
||||||
|
|
@ -616,15 +616,17 @@ describe('Boop - game flow', () => {
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(getParts(state).length).toBe(1);
|
expect(getParts(state).length).toBe(1);
|
||||||
expect(getParts(state)[0].id).toBe('white-cat-1');
|
expect(getParts(state)[0].id).toBe('white-cat-1');
|
||||||
expect(getParts(state)[0].value.pieceType).toBe('cat');
|
expect(getParts(state)[0].pieceType).toBe('cat');
|
||||||
expect(state.value.players.white.value.cat.supply).toBe(2);
|
expect(state.value.players.white.cat.supply).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject placing a cat when supply is empty', async () => {
|
it('should reject placing a cat when supply is empty', async () => {
|
||||||
const { ctx } = createTestContext();
|
const { ctx } = createTestContext();
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
|
|
||||||
state.value.players.white.value.cat.supply = 0;
|
state.produce(s => {
|
||||||
|
s.players.white.cat.supply = 0;
|
||||||
|
});
|
||||||
|
|
||||||
const promptPromise = waitForPrompt(ctx);
|
const promptPromise = waitForPrompt(ctx);
|
||||||
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
TicTacToeState,
|
TicTacToeState,
|
||||||
WinnerType, PlayerType
|
WinnerType, PlayerType
|
||||||
} from '@/samples/tic-tac-toe';
|
} from '@/samples/tic-tac-toe';
|
||||||
import {Entity} from "@/utils/entity";
|
import {MutableSignal} from "@/utils/mutable-signal";
|
||||||
import {createGameContext} from "@/";
|
import {createGameContext} from "@/";
|
||||||
import type { PromptEvent } from '@/utils/command';
|
import type { PromptEvent } from '@/utils/command';
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ function createTestContext() {
|
||||||
return { registry, ctx };
|
return { registry, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<TicTacToeState> {
|
function getState(ctx: ReturnType<typeof createTestContext>['ctx']): MutableSignal<TicTacToeState> {
|
||||||
return ctx.state;
|
return ctx.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,9 +163,9 @@ describe('TicTacToe - helper functions', () => {
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
placePiece(state, 1, 1, 'X');
|
placePiece(state, 1, 1, 'X');
|
||||||
|
|
||||||
expect(state.value.parts.length).toBe(1);
|
expect(Object.keys(state.value.parts).length).toBe(1);
|
||||||
expect(state.value.parts[0].value.position).toEqual([1, 1]);
|
expect(state.value.parts['piece-X-1'].position).toEqual([1, 1]);
|
||||||
expect(state.value.parts[0].value.player).toBe('X');
|
expect(state.value.parts['piece-X-1'].player).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
it('should add piece to board region children', () => {
|
||||||
|
|
@ -174,7 +174,7 @@ describe('TicTacToe - helper functions', () => {
|
||||||
placePiece(state, 0, 0, 'O');
|
placePiece(state, 0, 0, 'O');
|
||||||
|
|
||||||
const board = state.value.board;
|
const board = state.value.board;
|
||||||
expect(board.value.children.length).toBe(1);
|
expect(board.childIds.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique IDs for pieces', () => {
|
it('should generate unique IDs for pieces', () => {
|
||||||
|
|
@ -183,7 +183,7 @@ describe('TicTacToe - helper functions', () => {
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(state, 0, 0, 'X');
|
||||||
placePiece(state, 0, 1, 'O');
|
placePiece(state, 0, 1, 'O');
|
||||||
|
|
||||||
const ids = state.value.parts.map(p => p.id);
|
const ids = Object.keys(state.value.parts);
|
||||||
expect(new Set(ids).size).toBe(2);
|
expect(new Set(ids).size).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -229,8 +229,8 @@ describe('TicTacToe - game flow', () => {
|
||||||
const result = await runPromise;
|
const result = await runPromise;
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
expect(ctx.state.value.parts.length).toBe(1);
|
expect(Object.keys(ctx.state.value.parts).length).toBe(1);
|
||||||
expect(ctx.state.value.parts[0].value.position).toEqual([1, 1]);
|
expect(ctx.state.value.parts['piece-X-1'].position).toEqual([1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject move for wrong player and re-prompt', async () => {
|
it('should reject move for wrong player and re-prompt', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createEntityCollection, Entity, entity } from '@/utils/entity';
|
import { createEntityCollection, MutableSignal, mutableSignal } from '@/utils/mutable-signal';
|
||||||
|
|
||||||
type TestEntity = {
|
type TestEntity = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -82,16 +82,6 @@ describe('createEntityCollection', () => {
|
||||||
expect(collection.get('nonexistent')).toBeUndefined();
|
expect(collection.get('nonexistent')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct accessor id', () => {
|
|
||||||
const collection = createEntityCollection<TestEntity>();
|
|
||||||
const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
|
||||||
|
|
||||||
collection.add(testEntity);
|
|
||||||
|
|
||||||
const accessor = collection.get('e1');
|
|
||||||
expect(accessor.id).toBe('e1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle removing non-existent entity', () => {
|
it('should handle removing non-existent entity', () => {
|
||||||
const collection = createEntityCollection<TestEntity>();
|
const collection = createEntityCollection<TestEntity>();
|
||||||
const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||||
|
|
@ -116,20 +106,19 @@ describe('createEntityCollection', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Entity', () => {
|
describe('MutableSignal', () => {
|
||||||
it('should create entity with id and value', () => {
|
it('should create signal with initial value', () => {
|
||||||
const e = entity('test', { count: 1 });
|
const s = mutableSignal({ count: 1 });
|
||||||
expect(e.id).toBe('test');
|
expect(s.value.count).toBe(1);
|
||||||
expect(e.value.count).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce immutable updates', () => {
|
it('should produce immutable updates', () => {
|
||||||
const e = entity('test', { count: 1, items: [1, 2, 3] });
|
const s = mutableSignal({ count: 1, items: [1, 2, 3] });
|
||||||
e.produce(draft => {
|
s.produce(draft => {
|
||||||
draft.count = 2;
|
draft.count = 2;
|
||||||
draft.items.push(4);
|
draft.items.push(4);
|
||||||
});
|
});
|
||||||
expect(e.value.count).toBe(2);
|
expect(s.value.count).toBe(2);
|
||||||
expect(e.value.items).toEqual([1, 2, 3, 4]);
|
expect(s.value.items).toEqual([1, 2, 3, 4]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue