Compare commits

..

4 Commits

14 changed files with 514 additions and 589 deletions

View File

@ -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', { name: 'x', min: 0, max: 5 },
axes: [ { name: 'y', min: 0, max: 5 },
{ name: 'x', min: 0, max: 5 }, ]),
{ name: 'y', min: 0, max: 5 }, parts: {},
],
children: [],
}),
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 |

View File

@ -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,

View File

@ -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);
});
} }

View File

@ -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,
axes,
childIds: [],
partMap: {},
};
}
public constructor(id: string, t?: Region, options?: SignalOptions<Region>) { function buildPartMap(region: Region, parts: Record<string, Part>) {
super(id, t, options); const map: Record<string, string> = {};
this.partsMap = computed(() => { for (const childId of region.childIds) {
const result: Record<string, Entity<Part>> = {}; const part = parts[childId];
for (const child of this.value.children) { if (part) {
const key = child.value.position.join(','); map[part.position.join(',')] = childId;
result[key] = child; }
}
return result;
});
} }
return map;
} }
export function applyAlign(region: Entity<Region>) { export function applyAlign(region: Region, parts: Record<string, Part>) {
batch(() => { if (region.childIds.length === 0) return;
region.produce(applyAlignCore);
});
}
function applyAlignCore(region: Region) {
if (region.children.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;
}
region.partMap = buildPartMap(region, parts);
}
export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Region, position?: number[]) {
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++) {
const part = parts[i];
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
targetRegion.childIds.push(part.id);
if (positions && positions[i]) {
part.position = positions[i];
}
targetRegion.partMap[part.position.join(',')] = part.id;
part.regionId = targetRegion.id;
} }
} }
export function moveToRegion(part: Entity<Part>, targetRegion: Entity<Region>, position?: number[]) { export function removeFromRegion(part: Part, region: Region) {
const sourceRegion = part.value.region; region.childIds = region.childIds.filter(id => id !== part.id);
batch(() => { delete region.partMap[part.position.join(',')];
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[][]) {
batch(() => {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const sourceRegion = part.value.region;
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 (positions && positions[i]) draft.position = positions[i];
});
}
});
}
export function removeFromRegion(part: Entity<Part>) {
const region = part.value.region;
batch(() => {
region.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
});
}

View File

@ -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';

View File

@ -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', { name: 'x', min: 0, max: BOARD_SIZE - 1 },
axes: [ { name: 'y', min: 0, max: BOARD_SIZE - 1 },
{ name: 'x', min: 0, max: BOARD_SIZE - 1 }, ]),
{ name: 'y', min: 0, max: BOARD_SIZE - 1 }, pieces: [] as BoopPart[],
],
children: [],
}),
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(playerEntity, pieceType); decrementSupply(playerData, 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;
} }

View File

@ -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', { name: 'x', min: 0, max: BOARD_SIZE - 1 },
axes: [ { name: 'y', min: 0, max: BOARD_SIZE - 1 },
{ name: 'x', min: 0, max: BOARD_SIZE - 1 }, ]),
{ name: 'y', min: 0, max: BOARD_SIZE - 1 }, parts: {} as Record<string, TicTacToePart>,
],
children: [],
}),
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);
});
}); });
} }

View File

@ -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) => {

View File

@ -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;
/** 取消 promptPromise 被 reject */ /** 取消 promptPromise 被 reject */
cancel: (reason?: string) => void; cancel: (reason?: string) => void;
}; };

View File

@ -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)])),
}; };
}; };
@ -44,4 +44,4 @@ export function createEntityCollection<T>(): EntityCollection<T> {
add, add,
get get
} }
} }

View File

@ -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']);
}); });
}); });
}); });

View File

@ -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');

View File

@ -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 () => {

View File

@ -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]);
}); });
}); });