Compare commits

...

12 Commits

19 changed files with 805 additions and 737 deletions

70
AGENTS.md Normal file
View File

@ -0,0 +1,70 @@
# AGENTS.md - boardgame-core
## Commands
Prefer `bash.exe`(C:\Users\Administrator\AppData\Local\Microsoft\WindowsApps\bash.exe) over Powershell.
```bash
npm run build # Build ESM bundle + declarations to dist/
npm run test # Run vitest in watch mode
npm run test:run # Run vitest once (no watch)
npm run typecheck # Type-check without emitting (tsc --noEmit)
```
### Running a single test file
```bash
npx vitest run tests/samples/tic-tac-toe.test.ts
```
### Running a single test by name
```bash
npx vitest run -t "should detect horizontal win for X"
```
## Code Style
### Imports
- Use **double quotes** for local imports, **single quotes** for npm packages
- No path aliases — use relative `../` and `./` paths
### Formatting
- **4-space indentation**
- **Semicolons** at statement ends
- **Arrow functions** for callbacks; `function` keyword for methods needing `this` (e.g. command handlers)
- No trailing whitespace
### Naming Conventions
- **Types/Interfaces**: `PascalCase``Part`, `Region`, `Command`, `IGameContext`
- **Classes**: `PascalCase``Entity`, `AsyncQueue`, `Mulberry32RNG`
- **Functions**: `camelCase`, verb-first — `createGameContext`, `parseCommand`, `isValidMove`
- **Variables**: `camelCase`
- **Constants**: `UPPER_SNAKE_CASE``BOARD_SIZE`, `WINNING_LINES`
- **Test files**: `*.test.ts` mirroring `src/` structure under `tests/`
### Types
- **Strict TypeScript** is enabled — no `any`
- Use **generics** heavily: `Entity<T>`, `CommandRunner<TContext, TResult>`
- Use **type aliases** for object shapes (not interfaces)
- Use **discriminated unions** for results: `{ success: true; result: T } | { success: false; error: string }`
- Use `unknown` for untyped values, narrow with type guards or `as`
### Error Handling
- Prefer **result objects** over throwing: return `{ success, result/error }`
- When catching: `const error = e as Error;` then use `error.message`
- Use `throw new Error(...)` only for truly exceptional cases
- Validation error messages are in Chinese (e.g. `"参数不足"`)
### Testing
- **Vitest** with globals enabled (`describe`, `it`, `expect` available without import)
- Use `async/await` for async tests
- Narrow result types with `if (result.success)` before accessing `result.result`
- Define inline helper functions in test files when needed
- No mocking — use real implementations
- Test helpers: `createTestContext()`, `createTestRegion()`
## Architecture Notes
- **Reactivity**: `Entity<T>` extends Preact Signal — access state via `.value`, mutate via `.produce(draft => ...)`
- **Command system**: CLI-style parsing with schema validation via `inline-schema`
- **Prompt system**: Commands prompt for input via `PromptEvent` with `resolve`/`reject`
- **Barrel exports**: `src/index.ts` is the single public API surface

17
package-lock.json generated
View File

@ -10,8 +10,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@preact/signals-core": "^1.5.1", "@preact/signals-core": "^1.5.1",
"boardgame-core": "file:", "inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema",
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema" "mutative": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"tsup": "^8.0.2", "tsup": "^8.0.2",
@ -1337,10 +1337,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/boardgame-core": {
"resolved": "",
"link": true
},
"node_modules/bundle-require": { "node_modules/bundle-require": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@ -1813,6 +1809,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mutative": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz",
"integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==",
"license": "MIT",
"engines": {
"node": ">=14.0"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",

View File

@ -19,7 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@preact/signals-core": "^1.5.1", "@preact/signals-core": "^1.5.1",
"inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema" "inline-schema": "git+https://gitea.ayi-games.online/hypercross/inline-schema",
"mutative": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"tsup": "^8.0.2", "tsup": "^8.0.2",

View File

@ -1,49 +1,69 @@
import {createEntityCollection} from "../utils/entity"; import {entity, Entity} from "../utils/entity";
import {Part} from "./part";
import {Region} from "./region";
import { import {
Command, Command,
CommandRegistry, CommandRegistry,
type CommandRunner, CommandRunnerContext, CommandRunnerContext,
CommandRunnerContextExport, CommandSchema, CommandRunnerContextExport,
createCommandRunnerContext, parseCommandSchema, CommandSchema,
PromptEvent createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
registerCommand
} from "../utils/command"; } from "../utils/command";
import {AsyncQueue} from "../utils/async-queue";
export interface IGameContext { export interface IGameContext<TState extends Record<string, unknown> = {} > {
parts: ReturnType<typeof createEntityCollection<Part>>; state: Entity<TState>;
regions: ReturnType<typeof createEntityCollection<Region>>; commands: CommandRunnerContextExport<Entity<TState>>;
commands: CommandRunnerContextExport<IGameContext>; }
prompts: AsyncQueue<PromptEvent>;
export function createGameContext<TState extends Record<string, unknown> = {} >(
commandRegistry: CommandRegistry<Entity<TState>>,
initialState?: TState | (() => TState)
): IGameContext<TState> {
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
const state = entity('state', stateValue);
const commands = createCommandRunnerContext(commandRegistry, state);
return {
state,
commands
};
} }
/** /**
* creates a game context. * so that we can do `import * as tictactoe from './tic-tac-toe.ts';\n\n createGameContextFromModule(tictactoe);`
* expects a command registry already registered with commands. * @param module
* @param commandRegistry
*/ */
export function createGameContext(commandRegistry: CommandRegistry<IGameContext>) { export function createGameContextFromModule<TState extends Record<string, unknown> = {} >(
const parts = createEntityCollection<Part>(); module: {
const regions = createEntityCollection<Region>(); registry: CommandRegistry<Entity<TState>>,
const ctx: IGameContext = { createInitialState: () => TState
parts, },
regions, ): IGameContext<TState> {
commands: null!, return createGameContext(module.registry, module.createInitialState);
prompts: new AsyncQueue(),
};
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
ctx.commands.on('prompt', (prompt: PromptEvent) => ctx.prompts.push(prompt));
return ctx;
} }
export function createGameCommand<TResult>( export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
schema: CommandSchema | string, const registry = createCommandRegistry<Entity<TState>>();
run: (this: CommandRunnerContext<IGameContext>, command: Command) => Promise<TResult>
): CommandRunner<IGameContext, TResult> {
return { return {
registry,
add<TResult = unknown>(
schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
){
createGameCommand(registry, schema, run);
return this;
}
}
}
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
registry: CommandRegistry<Entity<TState>>,
schema: CommandSchema | string,
run: (this: CommandRunnerContext<Entity<TState>>, command: Command) => Promise<TResult>
) {
registerCommand(registry, {
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
run, run,
}; });
} }

View File

@ -1,33 +1,36 @@
import {Entity, EntityAccessor} from "../utils/entity"; import {Entity} from "../utils/entity";
import {Region} from "./region"; import {Region} from "./region";
import {RNG} from "../utils/rng"; import {RNG} from "../utils/rng";
export type Part = Entity & { export type Part = {
// cards have 2 sides, dices have multiple, tokens have 1 id: string;
sides: number;
sides?: number;
side?: number;
// mostly rotations, if relevant
alignments?: string[]; alignments?: string[];
// current side
side: number;
// current alignment
alignment?: string; alignment?: string;
region: Entity<Region>;
// current region
region: EntityAccessor<Region>;
// current position in region, expect to be the same length as region's axes
position: number[]; position: number[];
} }
export function flip(part: Part) { export function flip(part: Entity<Part>) {
part.side = (part.side + 1) % part.sides; part.produce(draft => {
if(!draft.sides)return;
draft.side = ((draft.side||0) + 1) % draft.sides;
});
} }
export function flipTo(part: Part, side: number) { export function flipTo(part: Entity<Part>, side: number) {
part.side = side; part.produce(draft => {
if(!draft.sides || side >= draft.sides)return;
draft.side = side;
});
} }
export function roll(part: Part, rng: RNG) { export function roll(part: Entity<Part>, rng: RNG) {
part.side = rng.nextInt(part.sides); part.produce(draft => {
if(!draft.sides)return;
draft.side = rng.nextInt(draft.sides);
});
} }

View File

@ -1,13 +1,11 @@
import {Entity, EntityAccessor} from "../utils/entity"; 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 = Entity & { export type Region = {
// aligning axes of the region, expect a part's position to have a matching number of elements id: string;
axes: RegionAxis[]; axes: RegionAxis[];
children: Entity<Part>[];
// current children; expect no overlapped positions
children: EntityAccessor<Part>[];
} }
export type RegionAxis = { export type RegionAxis = {
@ -17,47 +15,35 @@ export type RegionAxis = {
align?: 'start' | 'end' | 'center'; align?: 'start' | 'end' | 'center';
} }
/** export function applyAlign(region: Entity<Region>) {
* for each axis, try to remove gaps in positions. region.produce(applyAlignCore);
* - if min exists and align is start, and there are parts at (for example) min+2 and min+4, then move them to min and min+1 }
* - if max exists and align is end, and there are parts at (for example) max-2 and max-4, then move them to max-1 and max-3
* - for center, move parts to the center, possibly creating parts placed at 0.5 positions function applyAlignCore(region: Region) {
* - sort children so that they're in ascending order on each axes.
* @param region
*/
export function applyAlign(region: Region){
if (region.children.length === 0) return; if (region.children.length === 0) return;
// Process each axis independently while preserving spatial relationships
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;
// Collect all unique position values on this axis, preserving original order
const positionValues = new Set<number>(); const positionValues = new Set<number>();
for (const accessor of region.children) { for (const child of region.children) {
positionValues.add(accessor.value.position[axisIndex] ?? 0); positionValues.add(child.value.position[axisIndex] ?? 0);
} }
// Sort position values
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b); const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
// Create position mapping: old position -> new position
const positionMap = new Map<number, number>(); const positionMap = new Map<number, number>();
if (axis.align === 'start' && axis.min !== undefined) { if (axis.align === 'start' && axis.min !== undefined) {
// Compact from min, preserving relative order
sortedPositions.forEach((pos, index) => { sortedPositions.forEach((pos, index) => {
positionMap.set(pos, axis.min! + index); positionMap.set(pos, axis.min! + index);
}); });
} else if (axis.align === 'end' && axis.max !== undefined) { } else if (axis.align === 'end' && axis.max !== undefined) {
// Compact towards max
const count = sortedPositions.length; const count = sortedPositions.length;
sortedPositions.forEach((pos, index) => { sortedPositions.forEach((pos, index) => {
positionMap.set(pos, axis.max! - (count - 1 - index)); positionMap.set(pos, axis.max! - (count - 1 - index));
}); });
} else if (axis.align === 'center') { } else if (axis.align === 'center') {
// Center alignment
const count = sortedPositions.length; const count = sortedPositions.length;
const min = axis.min ?? 0; const min = axis.min ?? 0;
const max = axis.max ?? count - 1; const max = axis.max ?? count - 1;
@ -70,14 +56,14 @@ export function applyAlign(region: Region){
}); });
} }
// Apply position mapping to all parts for (const child of region.children) {
for (const accessor of region.children) { child.produce(draft => {
const currentPos = accessor.value.position[axisIndex] ?? 0; const currentPos = draft.position[axisIndex] ?? 0;
accessor.value.position[axisIndex] = positionMap.get(currentPos) ?? currentPos; draft.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
});
} }
} }
// Sort children by all axes at the end
region.children.sort((a, b) => { region.children.sort((a, b) => {
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.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
@ -87,21 +73,23 @@ export function applyAlign(region: Region){
}); });
} }
/** export function shuffle(region: Entity<Region>, rng: RNG) {
* shuffle on each axis. for each axis, try to swap position. region.produce(region => shuffleCore(region, rng));
* @param region }
* @param rng
*/ function shuffleCore(region: Region, rng: RNG){
export function shuffle(region: Region, rng: RNG){
if (region.children.length <= 1) return; if (region.children.length <= 1) return;
// Fisher-Yates 洗牌算法
const children = [...region.children]; const children = [...region.children];
for (let i = children.length - 1; i > 0; i--) { for (let i = children.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1); const j = rng.nextInt(i + 1);
// 交换两个 part 的整个 position 数组 const posI = [...children[i].value.position];
const temp = children[i].value.position; const posJ = [...children[j].value.position];
children[i].value.position = children[j].value.position; children[i].produce(draft => {
children[j].value.position = temp; draft.position = posJ;
});
children[j].produce(draft => {
draft.position = posI;
});
} }
} }

View File

@ -5,7 +5,7 @@
// Core types // Core types
export type { IGameContext } from './core/game'; export type { IGameContext } from './core/game';
export { createGameContext } from './core/game'; export { createGameContext, createGameCommandRegistry } from './core/game';
export type { Part } from './core/part'; export type { Part } from './core/part';
export { flip, flipTo, roll } from './core/part'; export { flip, flipTo, roll } from './core/part';
@ -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, EntityAccessor } from './utils/entity'; export type { Entity } from './utils/entity';
export { createEntityCollection } from './utils/entity'; export { createEntityCollection, entity } from './utils/entity';
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,31 +1,11 @@
import { IGameContext } from '../core/game'; import {createGameCommandRegistry} from '../';
import {CommandRegistry, CommandRunner, registerCommand} from '../utils/command'; import type { Part } from '../';
import type { Part } from '../core/part'; import {Entity, entity} from "../";
import {createGameCommand} from "../core/game"; import {Region} from "../";
export type TicTacToeState = { const BOARD_SIZE = 3;
currentPlayer: 'X' | 'O'; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
winner: 'X' | 'O' | 'draw' | null; const WINNING_LINES: number[][][] = [
moveCount: number;
};
type TurnResult = {
winner: 'X' | 'O' | 'draw' | null;
};
export function getBoardRegion(host: IGameContext) {
return host.regions.get('board');
}
export function isCellOccupied(host: IGameContext, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.value.children.some(
(child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col
);
}
export function hasWinningLine(positions: number[][]): boolean {
const lines = [
[[0, 0], [0, 1], [0, 2]], [[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]], [[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]], [[2, 0], [2, 1], [2, 2]],
@ -36,90 +16,128 @@ export function hasWinningLine(positions: number[][]): boolean {
[[0, 2], [1, 1], [2, 0]], [[0, 2], [1, 1], [2, 0]],
]; ];
return lines.some(line => export type PlayerType = 'X' | 'O';
export type WinnerType = PlayerType | 'draw' | null;
type TicTacToePart = Part & { player: PlayerType };
export function createInitialState() {
return {
board: entity<Region>('board', {
id: 'board',
axes: [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
],
children: [],
}),
parts: [] as Entity<TicTacToePart>[],
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;
registration.add('setup', async function() {
const {context} = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnNumber = context.value.turn + 1;
const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer} ${turnNumber}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
if (context.value.winner) break;
}
return context.value;
});
registration.add('turn <player> <turn:number>', async function(cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
const maxRetries = MAX_TURNS * 2;
let retries = 0;
while (retries < maxRetries) {
retries++;
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
const [player, row, col] = playCmd.params as [PlayerType, number, number];
if (player !== turnPlayer) continue;
if (!isValidMove(row, col)) continue;
if (isCellOccupied(this.context, row, col)) continue;
placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context);
if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
return { winner: null };
}
throw new Error('Too many invalid attempts');
});
function isValidMove(row: number, col: number): boolean {
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
export function getBoardRegion(host: Entity<TicTacToeState>) {
return host.value.board;
}
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.value.children.some(
part => part.value.position[0] === row && part.value.position[1] === col
);
}
export function hasWinningLine(positions: number[][]): boolean {
return WINNING_LINES.some(line =>
line.every(([r, c]) => line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c) positions.some(([pr, pc]) => pr === r && pc === c)
) )
); );
} }
export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null { export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value); const parts = host.value.parts.map((e: Entity<TicTacToePart>) => e.value);
const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position); const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position); const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
if (hasWinningLine(xPositions)) return 'X'; if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O'; if (hasWinningLine(oPositions)) return 'O';
if (parts.length >= MAX_TURNS) return 'draw';
return null; return null;
} }
export function placePiece(host: IGameContext, row: number, col: number, moveCount: number) { export function placePiece(host: Entity<TicTacToeState>, row: number, col: number, player: PlayerType) {
const board = getBoardRegion(host); const board = getBoardRegion(host);
const piece: Part = { const moveNumber = host.value.parts.length + 1;
id: `piece-${moveCount}`, const piece: TicTacToePart = {
sides: 1, id: `piece-${player}-${moveNumber}`,
side: 0,
region: board, region: board,
position: [row, col], position: [row, col],
player,
}; };
host.parts.add(piece); host.produce(state => {
board.value.children.push(host.parts.get(piece.id)); const e = entity(piece.id, piece)
} state.parts.push(e);
board.produce(draft => {
const setup = createGameCommand( draft.children.push(e);
'setup', });
async function() {
this.context.regions.add({
id: 'board',
axes: [
{ name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 2 },
],
children: [],
}); });
let currentPlayer: 'X' | 'O' = 'X';
let turnResult: TurnResult | undefined;
let turn = 1;
while (true) {
const turnOutput = await this.run<TurnResult>(`turn ${currentPlayer} ${turn++}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
turnResult = turnOutput?.result.winner;
if (turnResult) break;
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
}
return { winner: turnResult };
}
)
const turn = createGameCommand(
'turn <player> <turn:number>',
async function(cmd) {
const [turnPlayer, turnNumber] = cmd.params as [string, number];
while (true) {
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
const [player, row, col] = playCmd.params as [string, number, number];
if(turnPlayer !== player) continue;
if (isNaN(row) || isNaN(col) || row < 0 || row > 2 || col < 0 || col > 2) continue;
if (isCellOccupied(this.context, row, col)) continue;
placePiece(this.context, row, col, turnNumber);
const winner = checkWinner(this.context);
if (winner) return { winner };
if (turnNumber >= 9) return { winner: 'draw' as const };
}
}
);
export function registerTicTacToeCommands(registry: CommandRegistry<IGameContext>) {
registerCommand(registry, setup);
registerCommand(registry, turn);
} }

View File

@ -1,5 +1,5 @@
import { type ParseError } from 'inline-schema'; import { type ParseError } from 'inline-schema';
import type { Command, CommandSchema } from './types.js'; import type { Command, CommandSchema } from './types';
function validateCommandCore(command: Command, schema: CommandSchema): string[] { function validateCommandCore(command: Command, schema: CommandSchema): string[] {
const errors: string[] = []; const errors: string[] = [];

View File

@ -1,5 +1,5 @@
import type { Command, CommandSchema } from './types.js'; import type { Command, CommandSchema } from './types';
import { applyCommandSchema } from './command-validate.js'; import { applyCommandSchema } from './command-validate';
export function parseCommand(input: string): Command; export function parseCommand(input: string): Command;
export function parseCommand(input: string, schema: CommandSchema): Command; export function parseCommand(input: string, schema: CommandSchema): Command;

View File

@ -1,8 +1,9 @@
import type { Command, CommandSchema } from './types.js'; import type { Command, CommandSchema } from './types';
import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} from './command-runner.js'; import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} from './command-runner';
import { parseCommand } from './command-parse.js'; import { parseCommand } from './command-parse';
import { applyCommandSchema } from './command-validate.js'; import { applyCommandSchema } from './command-validate';
import { parseCommandSchema } from './schema-parse.js'; import { parseCommandSchema } from './schema-parse';
import {AsyncQueue} from "../async-queue";
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>; export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
@ -42,6 +43,7 @@ type Listener = (e: PromptEvent) => void;
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & { export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
registry: CommandRegistry<TContext>; registry: CommandRegistry<TContext>;
promptQueue: AsyncQueue<PromptEvent>;
_activePrompt: PromptEvent | null; _activePrompt: PromptEvent | null;
_resolvePrompt: (command: Command) => void; _resolvePrompt: (command: Command) => void;
_rejectPrompt: (error: Error) => void; _rejectPrompt: (error: Error) => void;
@ -101,12 +103,26 @@ export function createCommandRunnerContext<TContext>(
_resolvePrompt: resolvePrompt, _resolvePrompt: resolvePrompt,
_rejectPrompt: rejectPrompt, _rejectPrompt: rejectPrompt,
_pendingInput: null, _pendingInput: null,
promptQueue: null!
}; };
Object.defineProperty(runnerCtx, '_activePrompt', { Object.defineProperty(runnerCtx, '_activePrompt', {
get: () => activePrompt, get: () => activePrompt,
}); });
let promptQueue: AsyncQueue<PromptEvent>;
Object.defineProperty(runnerCtx, 'promptQueue', {
get(){
if (!promptQueue) {
promptQueue = new AsyncQueue();
listeners.add(async (event) => {
promptQueue.push(event);
});
}
return promptQueue;
}
});
return runnerCtx; return runnerCtx;
} }

View File

@ -1,4 +1,4 @@
import type { Command, CommandSchema } from './types.js'; import type { Command, CommandSchema } from './types';
export type PromptEvent = { export type PromptEvent = {
schema: CommandSchema; schema: CommandSchema;

View File

@ -1,7 +1,7 @@
import type { Command, CommandSchema } from './types.js'; import type { Command, CommandSchema } from './types';
import { parseCommand } from './command-parse.js'; import { parseCommand } from './command-parse';
import { parseCommandSchema } from './schema-parse.js'; import { parseCommandSchema } from './schema-parse';
import { applyCommandSchema as applyCommandSchemaCore } from './command-apply.js'; import { applyCommandSchema as applyCommandSchemaCore } from './command-apply';
export { applyCommandSchemaCore as applyCommandSchema }; export { applyCommandSchemaCore as applyCommandSchema };

View File

@ -1,5 +1,5 @@
import { defineSchema, type ParsedSchema } from 'inline-schema'; import { defineSchema, type ParsedSchema } from 'inline-schema';
import type { CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema, ParsedOptionResult } from './types.js'; import type { CommandSchema, ParsedOptionResult } from './types';
export function parseCommandSchema(schemaStr: string, name?: string): CommandSchema { export function parseCommandSchema(schemaStr: string, name?: string): CommandSchema {
const schema: CommandSchema = { const schema: CommandSchema = {

View File

@ -1,79 +1,42 @@
import {Signal, signal} from "@preact/signals-core"; import {Signal, signal, SignalOptions} from "@preact/signals-core";
import {create} from 'mutative';
export type Entity = { export class Entity<T> extends Signal<T> {
id: string; public constructor(public readonly id: string, t?: T, options?: SignalOptions<T>) {
}; super(t, options);
}
export type EntityAccessor<T extends Entity> = { produce(fn: (draft: T) => void) {
id: string; this.value = create(this.value, fn);
value: T; }
} }
function createReactiveProxy<T extends Entity>(entitySignal: Signal<T>): T { export function entity<T = undefined>(id: string, t?: T, options?: SignalOptions<T>) {
return new Proxy({} as T, { return new Entity<T>(id, t, options);
get(_target, prop) {
const current = entitySignal.value;
const value = current[prop as keyof T];
if (typeof value === 'function') {
return value.bind(current);
}
return value;
},
set(_target, prop, value) {
const current = entitySignal.value;
entitySignal.value = { ...current, [prop]: value };
return true;
},
ownKeys(_target) {
return Reflect.ownKeys(entitySignal.value);
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(entitySignal.value, prop);
},
});
} }
function createReactiveAccessor<T extends Entity>(id: string, entitySignal: Signal<T>): EntityAccessor<T> { export type EntityCollection<T> = {
const proxy = createReactiveProxy(entitySignal); collection: Signal<Record<string, Entity<T>>>;
return { remove(...ids: string[]): void;
id, add(...entities: (T & {id: string})[]): void;
get value() { get(id: string): Entity<T>;
return proxy;
},
set value(value: T) {
entitySignal.value = value;
}
} as EntityAccessor<T>;
} }
export function createEntityCollection<T extends Entity>() { export function createEntityCollection<T>(): EntityCollection<T> {
const collection = signal({} as Record<string, Signal<T>>); const collection = signal({} as Record<string, Entity<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)),
); );
}; };
const add = (...entities: T[]) => { const add = (...entities: (T & {id: string})[]) => {
collection.value = { collection.value = {
...collection.value, ...collection.value,
...Object.fromEntries(entities.map((entity) => [entity.id, signal(entity)])), ...Object.fromEntries(entities.map((e) => [e.id, entity(e.id, e)])),
}; };
}; };
const get = (id: string): EntityAccessor<T> => { const get = (id: string) => collection.value[id];
const entitySignal = collection.value[id];
if (!entitySignal) {
return {
id,
get value() {
return undefined as unknown as T;
},
set value(_value: T) {}
} as EntityAccessor<T>;
}
return createReactiveAccessor(id, entitySignal);
}
return { return {
collection, collection,

View File

@ -1,43 +1,67 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createGameContext, createGameCommand } from '../../src/core/game'; import { createGameContext, createGameCommand, createGameCommandRegistry, IGameContext } from '../../src/core/game';
import { createCommandRegistry, parseCommandSchema, type CommandRegistry } from '../../src/utils/command'; import { Entity } from '../../src/utils/entity';
import type { IGameContext } from '../../src/core/game'; import type { PromptEvent } from '../../src/utils/command';
type MyState = {
score: number;
round: number;
};
describe('createGameContext', () => { describe('createGameContext', () => {
it('should create a game context with empty parts and regions', () => { it('should create a game context with state', () => {
const registry = createCommandRegistry<IGameContext>(); const { registry } = createGameCommandRegistry();
const ctx = createGameContext(registry); const ctx = createGameContext(registry);
expect(ctx.parts.collection.value).toEqual({}); expect(ctx.state).not.toBeNull();
expect(ctx.regions.collection.value).toEqual({}); expect(ctx.state.value).toBeDefined();
}); });
it('should wire commands to the context', () => { it('should wire commands to the context', () => {
const registry = createCommandRegistry<IGameContext>(); const { registry } = createGameCommandRegistry();
const ctx = createGameContext(registry); const ctx = createGameContext(registry);
expect(ctx.commands).not.toBeNull(); expect(ctx.commands).not.toBeNull();
expect(ctx.commands.registry).toBe(registry); expect(ctx.commands.registry).toBe(registry);
expect(ctx.commands.context).toBe(ctx); expect(ctx.commands.context).toBe(ctx.state);
}); });
it('should forward prompt events to the prompts queue', async () => { it('should accept initial state as an object', () => {
const registry = createCommandRegistry<IGameContext>(); const { registry } = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, {
score: 0,
round: 1,
});
expect(ctx.state.value.score).toBe(0);
expect(ctx.state.value.round).toBe(1);
});
it('should accept initial state as a factory function', () => {
const { registry } = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, () => ({
score: 10,
round: 3,
}));
expect(ctx.state.value.score).toBe(10);
expect(ctx.state.value.round).toBe(3);
});
it('should forward prompt events via listener', async () => {
const { registry } = createGameCommandRegistry();
const ctx = createGameContext(registry); const ctx = createGameContext(registry);
const schema = parseCommandSchema('test <value>'); createGameCommand(registry, 'test <value>', async function () {
registry.set('test', {
schema,
run: async function () {
return this.prompt('prompt <answer>'); return this.prompt('prompt <answer>');
},
}); });
const promptPromise = new Promise<PromptEvent>(resolve => {
ctx.commands.on('prompt', resolve);
});
const runPromise = ctx.commands.run('test hello'); const runPromise = ctx.commands.run('test hello');
await new Promise((r) => setTimeout(r, 0)); const promptEvent = await promptPromise;
const promptEvent = await ctx.prompts.pop();
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('prompt'); expect(promptEvent.schema.name).toBe('prompt');
@ -52,75 +76,62 @@ describe('createGameContext', () => {
}); });
describe('createGameCommand', () => { describe('createGameCommand', () => {
it('should create a command from a string schema', () => {
const cmd = createGameCommand('test <a>', async function () {
return 1;
});
expect(cmd.schema.name).toBe('test');
expect(cmd.schema.params[0].name).toBe('a');
expect(cmd.schema.params[0].required).toBe(true);
});
it('should create a command from a CommandSchema object', () => {
const schema = parseCommandSchema('foo <x> [y]');
const cmd = createGameCommand(schema, async function () {
return 2;
});
expect(cmd.schema.name).toBe('foo');
expect(cmd.schema.params[0].name).toBe('x');
expect(cmd.schema.params[0].required).toBe(true);
expect(cmd.schema.params[1].name).toBe('y');
expect(cmd.schema.params[1].required).toBe(false);
});
it('should run a command with access to game context', async () => { it('should run a command with access to game context', async () => {
const registry = createCommandRegistry<IGameContext>(); const { registry } = createGameCommandRegistry<Entity<{ marker: string }>>();
const ctx = createGameContext(registry); const ctx = createGameContext(registry, { marker: '' });
const addRegion = createGameCommand('add-region <id>', async function (cmd) { createGameCommand(registry, 'set-marker <id>', async function (cmd) {
const id = cmd.params[0] as string; const id = cmd.params[0] as string;
this.context.regions.add({ id, axes: [], children: [] }); this.context.produce(state => {
state.marker = id;
});
return id; return id;
}); });
registry.set('add-region', addRegion); const result = await ctx.commands.run('set-marker board');
const result = await ctx.commands.run('add-region board');
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.result).toBe('board'); expect(result.result).toBe('board');
} }
expect(ctx.regions.get('board')).not.toBeNull(); expect(ctx.state.value.marker).toBe('board');
}); });
it('should run a command that adds parts', async () => { it('should run a typed command with extended context', async () => {
const registry = createCommandRegistry<IGameContext>(); const { registry } = createGameCommandRegistry<MyState>();
const ctx = createGameContext(registry);
ctx.regions.add({ id: 'zone', axes: [], children: [] }); createGameCommand<MyState, number>(
registry,
const addPart = createGameCommand('add-part <id>', async function (cmd) { 'add-score <amount:number>',
const id = cmd.params[0] as string; async function (cmd) {
const part = { const amount = cmd.params[0] as number;
id, this.context.produce(state => {
sides: 1, state.score += amount;
side: 0,
region: this.context.regions.get('zone'),
position: [0],
};
this.context.parts.add(part);
return id;
}); });
return this.context.value.score;
}
);
registry.set('add-part', addPart); const ctx = createGameContext<MyState>(registry, () => ({
score: 0,
round: 1,
}));
const result = await ctx.commands.run('add-part piece-1'); const result = await ctx.commands.run('add-score 5');
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.result).toBe('piece-1'); expect(result.result).toBe(5);
}
expect(ctx.state.value.score).toBe(5);
});
it('should return error for unknown command', async () => {
const { registry } = createGameCommandRegistry();
const ctx = createGameContext(registry);
const result = await ctx.commands.run('nonexistent');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('nonexistent');
} }
expect(ctx.parts.get('piece-1')).not.toBeNull();
}); });
}); });

View File

@ -1,155 +1,135 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { applyAlign, shuffle, type Region, type RegionAxis } from '../../src/core/region'; import { applyAlign, shuffle, type Region, type RegionAxis } from '../../src/core/region';
import { createRNG } from '../../src/utils/rng'; import { createRNG } from '../../src/utils/rng';
import { createEntityCollection } from '../../src/utils/entity'; import { entity, Entity } from '../../src/utils/entity';
import { type Part } from '../../src/core/part'; import { type Part } from '../../src/core/part';
describe('Region', () => { describe('Region', () => {
function createPart(id: string, position: number[]): Part { function createTestRegion(axes: RegionAxis[], parts: Part[]): Entity<Region> {
const collection = createEntityCollection<Part>(); const partEntities = parts.map(p => entity(p.id, p));
const part: Part = { return entity('region1', {
id,
sides: 1,
side: 0,
region: { id: 'region1', value: {} as Region },
position: [...position]
};
collection.add(part);
return part;
}
function createRegion(axes: RegionAxis[], parts: Part[]): Region {
const region: Region = {
id: 'region1', id: 'region1',
axes: [...axes], axes: [...axes],
children: parts.map(p => ({ id: p.id, value: p })) children: partEntities,
}; });
return region;
} }
describe('applyAlign', () => { describe('applyAlign', () => {
it('should do nothing with empty region', () => { it('should do nothing with empty region', () => {
const region = createRegion([{ name: 'x', min: 0, align: 'start' }], []); const region = createTestRegion([{ name: 'x', min: 0, align: 'start' }], []);
applyAlign(region); applyAlign(region);
expect(region.children).toHaveLength(0); expect(region.value.children).toHaveLength(0);
}); });
it('should align parts to start on first axis', () => { it('should align parts to start on first axis', () => {
const part1 = createPart('p1', [5, 10]); const part1: Part = { id: 'p1', region: null as any, position: [5, 10] };
const part2 = createPart('p2', [7, 20]); const part2: Part = { id: 'p2', region: null as any, position: [7, 20] };
const part3 = createPart('p3', [2, 30]); const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
const region = createRegion( const region = 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);
// 排序后应该是 part3(2), part1(5), part2(7) -> 对齐到 0, 1, 2 (第一轴) expect(region.value.children[0].value.position[0]).toBe(0);
expect(region.children[0].value.position[0]).toBe(0); expect(region.value.children[1].value.position[0]).toBe(1);
expect(region.children[1].value.position[0]).toBe(1); expect(region.value.children[2].value.position[0]).toBe(2);
expect(region.children[2].value.position[0]).toBe(2); expect(region.value.children[0].value.position[1]).toBe(30);
// 第二轴保持不变 expect(region.value.children[1].value.position[1]).toBe(10);
expect(region.children[0].value.position[1]).toBe(30); expect(region.value.children[2].value.position[1]).toBe(20);
expect(region.children[1].value.position[1]).toBe(10);
expect(region.children[2].value.position[1]).toBe(20);
}); });
it('should align parts to start with custom min', () => { it('should align parts to start with custom min', () => {
const part1 = createPart('p1', [5, 100]); const part1: Part = { id: 'p1', region: null as any, position: [5, 100] };
const part2 = createPart('p2', [7, 200]); const part2: Part = { id: 'p2', region: null as any, position: [7, 200] };
const region = createRegion( const region = 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);
expect(region.children[0].value.position[0]).toBe(10); expect(region.value.children[0].value.position[0]).toBe(10);
expect(region.children[1].value.position[0]).toBe(11); expect(region.value.children[1].value.position[0]).toBe(11);
// 第二轴保持不变 expect(region.value.children[0].value.position[1]).toBe(100);
expect(region.children[0].value.position[1]).toBe(100); expect(region.value.children[1].value.position[1]).toBe(200);
expect(region.children[1].value.position[1]).toBe(200);
}); });
it('should align parts to end on first axis', () => { it('should align parts to end on first axis', () => {
const part1 = createPart('p1', [2, 50]); const part1: Part = { id: 'p1', region: null as any, position: [2, 50] };
const part2 = createPart('p2', [4, 60]); const part2: Part = { id: 'p2', region: null as any, position: [4, 60] };
const part3 = createPart('p3', [1, 70]); const part3: Part = { id: 'p3', region: null as any, position: [1, 70] };
const region = createRegion( const region = 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);
// 3 个部分,对齐到 end(max=10),应该是 8, 9, 10 expect(region.value.children[0].value.position[0]).toBe(8);
expect(region.children[0].value.position[0]).toBe(8); expect(region.value.children[1].value.position[0]).toBe(9);
expect(region.children[1].value.position[0]).toBe(9); expect(region.value.children[2].value.position[0]).toBe(10);
expect(region.children[2].value.position[0]).toBe(10);
}); });
it('should align parts to center on first axis', () => { it('should align parts to center on first axis', () => {
const part1 = createPart('p1', [0, 5]); const part1: Part = { id: 'p1', region: null as any, position: [0, 5] };
const part2 = createPart('p2', [1, 6]); const part2: Part = { id: 'p2', region: null as any, position: [1, 6] };
const part3 = createPart('p3', [2, 7]); const part3: Part = { id: 'p3', region: null as any, position: [2, 7] };
const region = createRegion( const region = 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);
// 中心是 53 个部分应该是 4, 5, 6 expect(region.value.children[0].value.position[0]).toBe(4);
expect(region.children[0].value.position[0]).toBe(4); expect(region.value.children[1].value.position[0]).toBe(5);
expect(region.children[1].value.position[0]).toBe(5); expect(region.value.children[2].value.position[0]).toBe(6);
expect(region.children[2].value.position[0]).toBe(6);
}); });
it('should handle even count center alignment', () => { it('should handle even count center alignment', () => {
const part1 = createPart('p1', [0, 10]); const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
const part2 = createPart('p2', [1, 20]); const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
const region = createRegion( const region = 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);
// 中心是 52 个部分应该是 4.5, 5.5 expect(region.value.children[0].value.position[0]).toBe(4.5);
expect(region.children[0].value.position[0]).toBe(4.5); expect(region.value.children[1].value.position[0]).toBe(5.5);
expect(region.children[1].value.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 = createPart('p1', [5, 100]); const part1: Part = { id: 'p1', region: null as any, position: [5, 100] };
const part2 = createPart('p2', [1, 200]); const part2: Part = { id: 'p2', region: null as any, position: [1, 200] };
const part3 = createPart('p3', [3, 300]); const part3: Part = { id: 'p3', region: null as any, position: [3, 300] };
const region = createRegion( const region = 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);
// children 应该按第一轴位置排序 expect(region.value.children[0].value.id).toBe('p2');
expect(region.children[0].value.id).toBe('p2'); expect(region.value.children[1].value.id).toBe('p3');
expect(region.children[1].value.id).toBe('p3'); expect(region.value.children[2].value.id).toBe('p1');
expect(region.children[2].value.id).toBe('p1');
}); });
it('should align on multiple axes', () => { it('should align on multiple axes', () => {
const part1 = createPart('p1', [5, 10]); const part1: Part = { id: 'p1', region: null as any, position: [5, 10] };
const part2 = createPart('p2', [7, 20]); const part2: Part = { id: 'p2', region: null as any, position: [7, 20] };
const part3 = createPart('p3', [2, 30]); const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
const region = createRegion( const region = 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' }
@ -159,22 +139,11 @@ describe('Region', () => {
applyAlign(region); applyAlign(region);
// X 轴对齐: const positions = region.value.children.map(c => ({
// 唯一位置值:[2, 5, 7] -> 映射到 [0, 1, 2]
// part3: 2->0, part1: 5->1, part2: 7->2
// 结果part3=[0,30], part1=[1,10], part2=[2,20]
//
// Y 轴对齐:
// 唯一位置值:[10, 20, 30] -> 映射到 [0, 1, 2]
// part1: 10->0, part2: 20->1, part3: 30->2
// 最终part1=[1,0], part2=[2,1], part3=[0,2]
const positions = region.children.map(c => ({
id: c.value.id, id: c.value.id,
position: c.value.position position: c.value.position
})); }));
// children 按位置排序后的顺序
expect(positions[0].id).toBe('p3'); expect(positions[0].id).toBe('p3');
expect(positions[0].position).toEqual([0, 2]); expect(positions[0].position).toEqual([0, 2]);
@ -186,14 +155,12 @@ describe('Region', () => {
}); });
it('should align 4 elements on rectangle corners', () => { it('should align 4 elements on rectangle corners', () => {
// 4 个元素放在矩形的四个角:(0,0), (10,0), (10,1), (0,1) const part1: Part = { id: 'p1', region: null as any, position: [0, 0] };
// 期望:保持矩形布局,只是紧凑到 (0,0), (1,0), (1,1), (0,1) const part2: Part = { id: 'p2', region: null as any, position: [10, 0] };
const part1 = createPart('p1', [0, 0]); // 左下角 const part3: Part = { id: 'p3', region: null as any, position: [10, 1] };
const part2 = createPart('p2', [10, 0]); // 右下角 const part4: Part = { id: 'p4', region: null as any, position: [0, 1] };
const part3 = createPart('p3', [10, 1]); // 右上角
const part4 = createPart('p4', [0, 1]); // 左上角
const region = createRegion( const region = 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' }
@ -203,22 +170,11 @@ describe('Region', () => {
applyAlign(region); applyAlign(region);
// X 轴对齐: const positions = region.value.children.map(c => ({
// 唯一位置值:[0, 10] -> 映射到 [0, 1]
// part1: 0->0, part4: 0->0, part2: 10->1, part3: 10->1
// 结果part1=[0,0], part4=[0,1], part2=[1,0], part3=[1,1]
//
// Y 轴对齐:
// 唯一位置值:[0, 1] -> 映射到 [0, 1] (已经是紧凑的)
// part1: 0->0, part2: 0->0, part4: 1->1, part3: 1->1
// 最终part1=[0,0], part2=[1,0], part4=[0,1], part3=[1,1]
const positions = region.children.map(c => ({
id: c.value.id, id: c.value.id,
position: c.value.position position: c.value.position
})); }));
// children 按位置排序后的顺序:(0,0), (0,1), (1,0), (1,1)
expect(positions[0].id).toBe('p1'); expect(positions[0].id).toBe('p1');
expect(positions[0].position).toEqual([0, 0]); expect(positions[0].position).toEqual([0, 0]);
@ -235,35 +191,33 @@ describe('Region', () => {
describe('shuffle', () => { describe('shuffle', () => {
it('should do nothing with empty region', () => { it('should do nothing with empty region', () => {
const region = createRegion([], []); const region = createTestRegion([], []);
const rng = createRNG(42); const rng = createRNG(42);
shuffle(region, rng); shuffle(region, rng);
expect(region.children).toHaveLength(0); expect(region.value.children).toHaveLength(0);
}); });
it('should do nothing with single part', () => { it('should do nothing with single part', () => {
const part = createPart('p1', [0, 0, 0]); const part: Part = { id: 'p1', region: null as any, position: [0, 0, 0] };
const region = createRegion([], [part]); const region = createTestRegion([], [part]);
const rng = createRNG(42); const rng = createRNG(42);
shuffle(region, rng); shuffle(region, rng);
expect(region.children[0].value.position).toEqual([0, 0, 0]); expect(region.value.children[0].value.position).toEqual([0, 0, 0]);
}); });
it('should shuffle positions of multiple parts', () => { it('should shuffle positions of multiple parts', () => {
const part1 = createPart('p1', [0, 100]); const part1: Part = { id: 'p1', region: null as any, position: [0, 100] };
const part2 = createPart('p2', [1, 200]); const part2: Part = { id: 'p2', region: null as any, position: [1, 200] };
const part3 = createPart('p3', [2, 300]); const part3: Part = { id: 'p3', region: null as any, position: [2, 300] };
const region = createRegion([], [part1, part2, part3]); const region = createTestRegion([], [part1, part2, part3]);
const rng = createRNG(42); const rng = createRNG(42);
const originalPositions = region.children.map(c => [...c.value.position]); const originalPositions = region.value.children.map(c => [...c.value.position]);
shuffle(region, rng); shuffle(region, rng);
// 位置应该被交换 const newPositions = region.value.children.map(c => c.value.position);
const newPositions = region.children.map(c => c.value.position);
// 验证所有原始位置仍然存在(只是被交换了)
originalPositions.forEach(origPos => { originalPositions.forEach(origPos => {
const found = newPositions.some(newPos => const found = newPositions.some(newPos =>
newPos[0] === origPos[0] && newPos[1] === origPos[1] newPos[0] === origPos[0] && newPos[1] === origPos[1]
@ -274,50 +228,48 @@ describe('Region', () => {
it('should be deterministic with same seed', () => { it('should be deterministic with same seed', () => {
const createRegionForTest = () => { const createRegionForTest = () => {
const part1 = createPart('p1', [0, 10]); const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
const part2 = createPart('p2', [1, 20]); const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
const part3 = createPart('p3', [2, 30]); const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
return createRegion([], [part1, part2, part3]); return createTestRegion([], [part1, part2, part3]);
}; };
const region1 = createRegionForTest(); const setup1 = createRegionForTest();
const region2 = createRegionForTest(); const setup2 = createRegionForTest();
const rng1 = createRNG(42); const rng1 = createRNG(42);
const rng2 = createRNG(42); const rng2 = createRNG(42);
shuffle(region1, rng1); shuffle(setup1, rng1);
shuffle(region2, rng2); shuffle(setup2, rng2);
const positions1 = region1.children.map(c => c.value.position); const positions1 = setup1.value.children.map(c => c.value.position);
const positions2 = region2.children.map(c => c.value.position); const positions2 = setup2.value.children.map(c => c.value.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 createRegionForTest = () => {
const part1 = createPart('p1', [0, 10]); const part1: Part = { id: 'p1', region: null as any, position: [0, 10] };
const part2 = createPart('p2', [1, 20]); const part2: Part = { id: 'p2', region: null as any, position: [1, 20] };
const part3 = createPart('p3', [2, 30]); const part3: Part = { id: 'p3', region: null as any, position: [2, 30] };
const part4 = createPart('p4', [3, 40]); const part4: Part = { id: 'p4', region: null as any, position: [3, 40] };
const part5 = createPart('p5', [4, 50]); const part5: Part = { id: 'p5', region: null as any, position: [4, 50] };
return createRegion([], [part1, part2, part3, part4, part5]); 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 region = createRegionForTest(); const setup = createRegionForTest();
const rng = createRNG(seed); const rng = createRNG(seed);
shuffle(region, rng); shuffle(setup, rng);
const positions = JSON.stringify(region.children.map(c => c.value.position)); const positions = JSON.stringify(setup.value.children.map(c => c.value.position));
results.add(positions); results.add(positions);
} }
// 10 个种子中至少应该有 5 个不同的结果
expect(results.size).toBeGreaterThan(5); expect(results.size).toBeGreaterThan(5);
}); });
}); });

View File

@ -1,189 +1,209 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createGameContext } from '../../src/core/game'; import {
import { createCommandRegistry } from '../../src/utils/command'; registry,
import { registerTicTacToeCommands, checkWinner, isCellOccupied, placePiece } from '../../src/samples/tic-tac-toe'; checkWinner,
import type { IGameContext } from '../../src/core/game'; isCellOccupied,
import type { Part } from '../../src/core/part'; placePiece,
createInitialState,
TicTacToeState,
WinnerType, PlayerType
} from '../../src/samples/tic-tac-toe';
import {Entity} from "../../src/utils/entity";
import {createGameContext} from "../../src";
import type { PromptEvent } from '../../src/utils/command';
function createTestContext() { function createTestContext() {
const registry = createCommandRegistry<IGameContext>(); const ctx = createGameContext(registry, createInitialState);
registerTicTacToeCommands(registry);
const ctx = createGameContext(registry);
return { registry, ctx }; return { registry, ctx };
} }
function setupBoard(ctx: IGameContext) { function getState(ctx: ReturnType<typeof createTestContext>['ctx']): Entity<TicTacToeState> {
ctx.regions.add({ return ctx.state;
id: 'board',
axes: [
{ name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 2 },
],
children: [],
});
} }
function addPiece(ctx: IGameContext, id: string, row: number, col: number) { function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
const board = ctx.regions.get('board'); return new Promise(resolve => {
const part: Part = { ctx.commands.on('prompt', resolve);
id, });
sides: 1,
side: 0,
region: board,
position: [row, col],
};
ctx.parts.add(part);
board.value.children.push(ctx.parts.get(id));
} }
describe('TicTacToe - helper functions', () => { describe('TicTacToe - helper functions', () => {
describe('checkWinner', () => { describe('checkWinner', () => {
it('should return null for empty board', () => { it('should return null for empty board', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
expect(checkWinner(ctx)).toBeNull(); expect(checkWinner(state)).toBeNull();
}); });
it('should detect horizontal win for X', () => { it('should detect horizontal win for X', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-2', 1, 0); placePiece(state, 1, 0, 'O');
addPiece(ctx, 'piece-3', 0, 1); placePiece(state, 0, 1, 'X');
addPiece(ctx, 'piece-4', 1, 1); placePiece(state, 1, 1, 'O');
addPiece(ctx, 'piece-5', 0, 2); placePiece(state, 0, 2, 'X');
expect(checkWinner(ctx)).toBe('X'); expect(checkWinner(state)).toBe('X');
}); });
it('should detect horizontal win for O', () => { it('should detect horizontal win for O', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 2, 0); placePiece(state, 2, 0, 'X');
addPiece(ctx, 'piece-2', 1, 0); placePiece(state, 1, 0, 'O');
addPiece(ctx, 'piece-3', 2, 1); placePiece(state, 2, 1, 'X');
addPiece(ctx, 'piece-4', 1, 1); placePiece(state, 1, 1, 'O');
addPiece(ctx, 'piece-5', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-6', 1, 2); placePiece(state, 1, 2, 'O');
expect(checkWinner(ctx)).toBe('O'); expect(checkWinner(state)).toBe('O');
}); });
it('should detect vertical win', () => { it('should detect vertical win', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-2', 0, 1); placePiece(state, 0, 1, 'O');
addPiece(ctx, 'piece-3', 1, 0); placePiece(state, 1, 0, 'X');
addPiece(ctx, 'piece-4', 1, 1); placePiece(state, 1, 1, 'O');
addPiece(ctx, 'piece-5', 2, 0); placePiece(state, 2, 0, 'X');
expect(checkWinner(ctx)).toBe('X'); expect(checkWinner(state)).toBe('X');
}); });
it('should detect diagonal win (top-left to bottom-right)', () => { it('should detect diagonal win (top-left to bottom-right)', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-2', 0, 1); placePiece(state, 0, 1, 'O');
addPiece(ctx, 'piece-3', 1, 1); placePiece(state, 1, 1, 'X');
addPiece(ctx, 'piece-4', 0, 2); placePiece(state, 0, 2, 'O');
addPiece(ctx, 'piece-5', 2, 2); placePiece(state, 2, 2, 'X');
expect(checkWinner(ctx)).toBe('X'); expect(checkWinner(state)).toBe('X');
}); });
it('should detect diagonal win (top-right to bottom-left)', () => { it('should detect diagonal win (top-right to bottom-left)', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-2', 0, 2); placePiece(state, 0, 2, 'O');
addPiece(ctx, 'piece-3', 1, 0); placePiece(state, 1, 0, 'X');
addPiece(ctx, 'piece-4', 1, 1); placePiece(state, 1, 1, 'O');
addPiece(ctx, 'piece-5', 1, 2); placePiece(state, 1, 2, 'X');
addPiece(ctx, 'piece-6', 2, 0); placePiece(state, 2, 0, 'O');
expect(checkWinner(ctx)).toBe('O'); expect(checkWinner(state)).toBe('O');
}); });
it('should return null for no winner', () => { it('should return null for no winner', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
addPiece(ctx, 'piece-2', 0, 1); placePiece(state, 0, 1, 'O');
addPiece(ctx, 'piece-3', 1, 2); placePiece(state, 1, 2, 'X');
expect(checkWinner(ctx)).toBeNull(); expect(checkWinner(state)).toBeNull();
});
it('should return draw when board is full with no winner', () => {
const { ctx } = createTestContext();
const state = getState(ctx);
const drawPositions = [
[0, 0, 'X'], [0, 1, 'O'], [0, 2, 'X'],
[1, 0, 'X'], [1, 1, 'O'], [1, 2, 'O'],
[2, 0, 'O'], [2, 1, 'X'], [2, 2, 'X'],
] as [number, number, PlayerType][];
drawPositions.forEach(([r, c, p], i) => {
placePiece(state, r, c, p);
});
expect(checkWinner(state)).toBe('draw');
}); });
}); });
describe('isCellOccupied', () => { describe('isCellOccupied', () => {
it('should return false for empty cell', () => { it('should return false for empty cell', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
expect(isCellOccupied(ctx, 1, 1)).toBe(false); expect(isCellOccupied(state, 1, 1)).toBe(false);
}); });
it('should return true for occupied cell', () => { it('should return true for occupied cell', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 1, 1); placePiece(state, 1, 1, 'X');
expect(isCellOccupied(ctx, 1, 1)).toBe(true); expect(isCellOccupied(state, 1, 1)).toBe(true);
}); });
it('should return false for different cell', () => { it('should return false for different cell', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-1', 0, 0); placePiece(state, 0, 0, 'X');
expect(isCellOccupied(ctx, 1, 1)).toBe(false); expect(isCellOccupied(state, 1, 1)).toBe(false);
}); });
}); });
describe('placePiece', () => { describe('placePiece', () => {
it('should add a piece to the board', () => { it('should add a piece to the board', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
placePiece(ctx, 1, 1, 1); placePiece(state, 1, 1, 'X');
expect(ctx.parts.get('piece-1')).not.toBeNull(); expect(state.value.parts.length).toBe(1);
expect(ctx.parts.get('piece-1').value.position).toEqual([1, 1]); expect(state.value.parts[0].value.position).toEqual([1, 1]);
expect(state.value.parts[0].value.player).toBe('X');
}); });
it('should add piece to board region children', () => { it('should add piece to board region children', () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
placePiece(ctx, 0, 0, 1); placePiece(state, 0, 0, 'O');
const board = ctx.regions.get('board'); const board = state.value.board;
expect(board.value.children.length).toBe(1); expect(board.value.children.length).toBe(1);
}); });
it('should generate unique IDs for pieces', () => {
const { ctx } = createTestContext();
const state = getState(ctx);
placePiece(state, 0, 0, 'X');
placePiece(state, 0, 1, 'O');
const ids = state.value.parts.map(p => p.id);
expect(new Set(ids).size).toBe(2);
});
}); });
}); });
describe('TicTacToe - game flow', () => { describe('TicTacToe - game flow', () => {
it('should have setup and turn commands registered', () => { it('should have setup and turn commands registered', () => {
const { registry } = createTestContext(); const { registry: reg } = createTestContext();
expect(registry.has('setup')).toBe(true); expect(reg.has('setup')).toBe(true);
expect(registry.has('turn')).toBe(true); expect(reg.has('turn')).toBe(true);
}); });
it('should setup board when setup command runs', async () => { it('should setup board when setup command runs', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run('setup'); const runPromise = ctx.commands.run('setup');
const promptEvent = await ctx.prompts.pop(); const promptEvent = await promptPromise;
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
@ -195,152 +215,135 @@ describe('TicTacToe - game flow', () => {
it('should accept valid move via turn command', async () => { it('should accept valid move via turn command', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx);
const runPromise = ctx.commands.run('turn X 1'); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
const promptEvent = await ctx.prompts.pop(); const promptEvent = await promptPromise;
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
promptEvent.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); promptEvent.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// After valid non-winning move, turn command prompts again, reject to stop
const promptEvent2 = await ctx.prompts.pop();
promptEvent2.reject(new Error('done'));
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
expect(ctx.parts.get('piece-1')).not.toBeNull(); if (result.success) expect(result.result.winner).toBeNull();
expect(ctx.parts.get('piece-1').value.position).toEqual([1, 1]); expect(ctx.state.value.parts.length).toBe(1);
expect(ctx.state.value.parts[0].value.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 () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx);
const runPromise = ctx.commands.run('turn X 1'); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
const promptEvent1 = await ctx.prompts.pop(); const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} }); promptEvent1.resolve({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} });
const promptEvent2 = await ctx.prompts.pop(); const promptEvent2 = await waitForPrompt(ctx);
expect(promptEvent2).not.toBeNull(); expect(promptEvent2).not.toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); promptEvent2.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// After valid non-winning move, reject next prompt
const promptEvent3 = await ctx.prompts.pop();
promptEvent3.reject(new Error('done'));
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
}); });
it('should reject move to occupied cell and re-prompt', async () => { it('should reject move to occupied cell and re-prompt', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
addPiece(ctx, 'piece-0', 1, 1); placePiece(state, 1, 1, 'O');
const runPromise = ctx.commands.run('turn X 1'); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
const promptEvent1 = await ctx.prompts.pop(); const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); promptEvent1.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
const promptEvent2 = await ctx.prompts.pop(); const promptEvent2 = await waitForPrompt(ctx);
expect(promptEvent2).not.toBeNull(); expect(promptEvent2).not.toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} }); promptEvent2.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
// After valid non-winning move, reject next prompt
const promptEvent3 = await ctx.prompts.pop();
promptEvent3.reject(new Error('done'));
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
}); });
it('should detect winner after winning move', async () => { it('should detect winner after winning move', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx);
// X plays (0,0) let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.commands.run('turn X 1'); let runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
let prompt = await ctx.prompts.pop(); let prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
let promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
let result = await runPromise; let result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
// O plays (0,1) promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn O 2'); runPromise = ctx.commands.run('turn O 2');
prompt = await ctx.prompts.pop(); prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise; result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
// X plays (1,0) promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn X 3'); runPromise = ctx.commands.run('turn X 3');
prompt = await ctx.prompts.pop(); prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise; result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
// O plays (0,2) promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn O 4'); runPromise = ctx.commands.run('turn O 4');
prompt = await ctx.prompts.pop(); prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise; result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull();
// X plays (2,0) - wins with vertical line promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn X 5'); runPromise = ctx.commands.run('turn X 5');
prompt = await ctx.prompts.pop(); prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} });
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect((result.result as any).winner).toBe('X'); if (result.success) expect(result.result.winner).toBe('X');
}); });
it('should detect draw after 9 moves', async () => { it('should detect draw after 9 moves', async () => {
const { ctx } = createTestContext(); const { ctx } = createTestContext();
setupBoard(ctx); const state = getState(ctx);
// Pre-place 8 pieces that don't form any winning line for either player
// Using positions that clearly don't form lines
// X pieces at even indices, O pieces at odd indices
const pieces = [ const pieces = [
{ id: 'p1', pos: [0, 0] }, // X { id: 'p1', pos: [0, 0], player: 'X' },
{ id: 'p2', pos: [2, 2] }, // O { id: 'p2', pos: [2, 2], player: 'O' },
{ id: 'p3', pos: [0, 2] }, // X { id: 'p3', pos: [0, 2], player: 'X' },
{ id: 'p4', pos: [2, 0] }, // O { id: 'p4', pos: [2, 0], player: 'O' },
{ id: 'p5', pos: [1, 0] }, // X { id: 'p5', pos: [1, 0], player: 'X' },
{ id: 'p6', pos: [0, 1] }, // O { id: 'p6', pos: [0, 1], player: 'O' },
{ id: 'p7', pos: [2, 1] }, // X { id: 'p7', pos: [2, 1], player: 'X' },
{ id: 'p8', pos: [1, 2] }, // O { id: 'p8', pos: [1, 2], player: 'O' },
]; ] as { id: string, pos: [number, number], player: PlayerType}[];
for (const { id, pos } of pieces) { for (const { id, pos, player } of pieces) {
addPiece(ctx, id, pos[0], pos[1]); placePiece(state, pos[0], pos[1], player);
} }
// Verify no winner before 9th move expect(checkWinner(state)).toBeNull();
expect(checkWinner(ctx)).toBeNull();
// Now X plays (1,1) for the 9th move -> draw const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run('turn X 9'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 9');
const prompt = await ctx.prompts.pop(); const prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); prompt.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect((result.result as any).winner).toBe('draw'); if (result.success) expect(result.result.winner).toBe('draw');
}); });
}); });

View File

@ -1,10 +1,11 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createEntityCollection, type Entity } from '../../src/utils/entity'; import { createEntityCollection, Entity, entity } from '../../src/utils/entity';
interface TestEntity extends Entity { type TestEntity = {
id: string;
name: string; name: string;
value: number; value: number;
} };
describe('createEntityCollection', () => { describe('createEntityCollection', () => {
it('should create empty collection', () => { it('should create empty collection', () => {
@ -14,12 +15,12 @@ describe('createEntityCollection', () => {
it('should add single entity', () => { it('should add single entity', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(entity); collection.add(testEntity);
expect(collection.collection.value).toHaveProperty('e1'); expect(collection.collection.value).toHaveProperty('e1');
expect(collection.get('e1').value).toEqual(entity); expect(collection.get('e1').value).toEqual(testEntity);
}); });
it('should add multiple entities', () => { it('should add multiple entities', () => {
@ -64,12 +65,12 @@ describe('createEntityCollection', () => {
it('should update entity via accessor', () => { it('should update entity via accessor', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(entity); collection.add(testEntity);
const accessor = collection.get('e1'); const accessor = collection.get('e1');
accessor.value = { ...entity, value: 100, name: 'Updated' }; accessor.value = { ...testEntity, value: 100, name: 'Updated' };
expect(collection.get('e1').value.value).toBe(100); expect(collection.get('e1').value.value).toBe(100);
expect(collection.get('e1').value.name).toBe('Updated'); expect(collection.get('e1').value.name).toBe('Updated');
@ -78,14 +79,14 @@ describe('createEntityCollection', () => {
it('should return undefined for non-existent entity', () => { it('should return undefined for non-existent entity', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
expect(collection.get('nonexistent').value).toBeUndefined(); expect(collection.get('nonexistent')).toBeUndefined();
}); });
it('should have correct accessor id', () => { it('should have correct accessor id', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(entity); collection.add(testEntity);
const accessor = collection.get('e1'); const accessor = collection.get('e1');
expect(accessor.id).toBe('e1'); expect(accessor.id).toBe('e1');
@ -93,9 +94,9 @@ describe('createEntityCollection', () => {
it('should handle removing non-existent entity', () => { it('should handle removing non-existent entity', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(entity); collection.add(testEntity);
collection.remove('nonexistent'); collection.remove('nonexistent');
expect(Object.keys(collection.collection.value)).toHaveLength(1); expect(Object.keys(collection.collection.value)).toHaveLength(1);
@ -103,15 +104,32 @@ describe('createEntityCollection', () => {
it('should work with reactive updates', () => { it('should work with reactive updates', () => {
const collection = createEntityCollection<TestEntity>(); const collection = createEntityCollection<TestEntity>();
const entity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 }; const testEntity: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
collection.add(entity); collection.add(testEntity);
// 验证 accessor 可以正确获取和设置值
const accessor = collection.get('e1'); const accessor = collection.get('e1');
expect(accessor.value.value).toBe(10); expect(accessor.value.value).toBe(10);
accessor.value = { ...entity, value: 50 }; accessor.value = { ...testEntity, value: 50 };
expect(accessor.value.value).toBe(50); expect(accessor.value.value).toBe(50);
}); });
}); });
describe('Entity', () => {
it('should create entity with id and value', () => {
const e = entity('test', { count: 1 });
expect(e.id).toBe('test');
expect(e.value.count).toBe(1);
});
it('should produce immutable updates', () => {
const e = entity('test', { count: 1, items: [1, 2, 3] });
e.produce(draft => {
draft.count = 2;
draft.items.push(4);
});
expect(e.value.count).toBe(2);
expect(e.value.items).toEqual([1, 2, 3, 4]);
});
});