Compare commits
12 Commits
846badc081
...
f4649e0dac
| Author | SHA1 | Date |
|---|---|---|
|
|
f4649e0dac | |
|
|
4f49599527 | |
|
|
bd9569992b | |
|
|
0948e5a742 | |
|
|
b7c5312b60 | |
|
|
e945d28fc3 | |
|
|
b2b35c3a99 | |
|
|
e3bc63b088 | |
|
|
e78caf481d | |
|
|
705e2f9396 | |
|
|
74f56b9da4 | |
|
|
004d49c36f |
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
// mostly rotations, if relevant
|
side?: number;
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -1,125 +1,143 @@
|
||||||
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;
|
[[0, 0], [0, 1], [0, 2]],
|
||||||
};
|
[[1, 0], [1, 1], [1, 2]],
|
||||||
|
[[2, 0], [2, 1], [2, 2]],
|
||||||
|
[[0, 0], [1, 0], [2, 0]],
|
||||||
|
[[0, 1], [1, 1], [2, 1]],
|
||||||
|
[[0, 2], [1, 2], [2, 2]],
|
||||||
|
[[0, 0], [1, 1], [2, 2]],
|
||||||
|
[[0, 2], [1, 1], [2, 0]],
|
||||||
|
];
|
||||||
|
|
||||||
type TurnResult = {
|
export type PlayerType = 'X' | 'O';
|
||||||
winner: 'X' | 'O' | 'draw' | null;
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
};
|
|
||||||
|
|
||||||
export function getBoardRegion(host: IGameContext) {
|
type TicTacToePart = Part & { player: PlayerType };
|
||||||
return host.regions.get('board');
|
|
||||||
|
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 isCellOccupied(host: IGameContext, row: number, col: number): boolean {
|
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);
|
const board = getBoardRegion(host);
|
||||||
return board.value.children.some(
|
return board.value.children.some(
|
||||||
(child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col
|
part => part.value.position[0] === row && part.value.position[1] === col
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
const lines = [
|
return WINNING_LINES.some(line =>
|
||||||
[[0, 0], [0, 1], [0, 2]],
|
|
||||||
[[1, 0], [1, 1], [1, 2]],
|
|
||||||
[[2, 0], [2, 1], [2, 2]],
|
|
||||||
[[0, 0], [1, 0], [2, 0]],
|
|
||||||
[[0, 1], [1, 1], [2, 1]],
|
|
||||||
[[0, 2], [1, 2], [2, 2]],
|
|
||||||
[[0, 0], [1, 1], [2, 2]],
|
|
||||||
[[0, 2], [1, 1], [2, 0]],
|
|
||||||
];
|
|
||||||
|
|
||||||
return 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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,11 +103,25 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,43 @@
|
||||||
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,
|
||||||
remove,
|
remove,
|
||||||
|
|
|
||||||
|
|
@ -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(registry);
|
const ctx = createGameContext<MyState>(registry, {
|
||||||
|
score: 0,
|
||||||
const schema = parseCommandSchema('test <value>');
|
round: 1,
|
||||||
registry.set('test', {
|
|
||||||
schema,
|
|
||||||
run: async function () {
|
|
||||||
return this.prompt('prompt <answer>');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
createGameCommand(registry, 'test <value>', async function () {
|
||||||
|
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,
|
||||||
|
'add-score <amount:number>',
|
||||||
|
async function (cmd) {
|
||||||
|
const amount = cmd.params[0] as number;
|
||||||
|
this.context.produce(state => {
|
||||||
|
state.score += amount;
|
||||||
|
});
|
||||||
|
return this.context.value.score;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const addPart = createGameCommand('add-part <id>', async function (cmd) {
|
const ctx = createGameContext<MyState>(registry, () => ({
|
||||||
const id = cmd.params[0] as string;
|
score: 0,
|
||||||
const part = {
|
round: 1,
|
||||||
id,
|
}));
|
||||||
sides: 1,
|
|
||||||
side: 0,
|
|
||||||
region: this.context.regions.get('zone'),
|
|
||||||
position: [0],
|
|
||||||
};
|
|
||||||
this.context.parts.add(part);
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.set('add-part', addPart);
|
const result = await ctx.commands.run('add-score 5');
|
||||||
|
|
||||||
const result = await ctx.commands.run('add-part piece-1');
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,233 +1,189 @@
|
||||||
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);
|
||||||
|
|
||||||
// 中心是 5,3 个部分应该是 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);
|
||||||
|
|
||||||
// 中心是 5,2 个部分应该是 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' }
|
||||||
],
|
],
|
||||||
[part1, part2, part3]
|
[part1, part2, part3]
|
||||||
);
|
);
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
expect(positions[1].id).toBe('p1');
|
expect(positions[1].id).toBe('p1');
|
||||||
expect(positions[1].position).toEqual([1, 0]);
|
expect(positions[1].position).toEqual([1, 0]);
|
||||||
|
|
||||||
expect(positions[2].id).toBe('p2');
|
expect(positions[2].id).toBe('p2');
|
||||||
expect(positions[2].position).toEqual([2, 1]);
|
expect(positions[2].position).toEqual([2, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = createTestRegion(
|
||||||
|
|
||||||
const region = createRegion(
|
|
||||||
[
|
[
|
||||||
{ 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' }
|
||||||
],
|
],
|
||||||
[part1, part2, part3, part4]
|
[part1, part2, part3, part4]
|
||||||
);
|
);
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
expect(positions[1].id).toBe('p4');
|
expect(positions[1].id).toBe('p4');
|
||||||
expect(positions[1].position).toEqual([0, 1]);
|
expect(positions[1].position).toEqual([0, 1]);
|
||||||
|
|
||||||
expect(positions[2].id).toBe('p2');
|
expect(positions[2].id).toBe('p2');
|
||||||
expect(positions[2].position).toEqual([1, 0]);
|
expect(positions[2].position).toEqual([1, 0]);
|
||||||
|
|
||||||
expect(positions[3].id).toBe('p3');
|
expect(positions[3].id).toBe('p3');
|
||||||
expect(positions[3].position).toEqual([1, 1]);
|
expect(positions[3].position).toEqual([1, 1]);
|
||||||
});
|
});
|
||||||
|
|
@ -235,37 +191,35 @@ 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]
|
||||||
);
|
);
|
||||||
expect(found).toBe(true);
|
expect(found).toBe(true);
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
@ -27,9 +28,9 @@ describe('createEntityCollection', () => {
|
||||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||||
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
||||||
|
|
||||||
collection.add(entity1, entity2, entity3);
|
collection.add(entity1, entity2, entity3);
|
||||||
|
|
||||||
expect(Object.keys(collection.collection.value)).toHaveLength(3);
|
expect(Object.keys(collection.collection.value)).toHaveLength(3);
|
||||||
expect(collection.get('e1').value.name).toBe('Entity 1');
|
expect(collection.get('e1').value.name).toBe('Entity 1');
|
||||||
expect(collection.get('e2').value.name).toBe('Entity 2');
|
expect(collection.get('e2').value.name).toBe('Entity 2');
|
||||||
|
|
@ -40,10 +41,10 @@ describe('createEntityCollection', () => {
|
||||||
const collection = createEntityCollection<TestEntity>();
|
const collection = createEntityCollection<TestEntity>();
|
||||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||||
|
|
||||||
collection.add(entity1, entity2);
|
collection.add(entity1, entity2);
|
||||||
collection.remove('e1');
|
collection.remove('e1');
|
||||||
|
|
||||||
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
||||||
expect(collection.collection.value).not.toHaveProperty('e1');
|
expect(collection.collection.value).not.toHaveProperty('e1');
|
||||||
expect(collection.collection.value).toHaveProperty('e2');
|
expect(collection.collection.value).toHaveProperty('e2');
|
||||||
|
|
@ -54,64 +55,81 @@ describe('createEntityCollection', () => {
|
||||||
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
const entity1: TestEntity = { id: 'e1', name: 'Entity 1', value: 10 };
|
||||||
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
const entity2: TestEntity = { id: 'e2', name: 'Entity 2', value: 20 };
|
||||||
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
const entity3: TestEntity = { id: 'e3', name: 'Entity 3', value: 30 };
|
||||||
|
|
||||||
collection.add(entity1, entity2, entity3);
|
collection.add(entity1, entity2, entity3);
|
||||||
collection.remove('e1', 'e3');
|
collection.remove('e1', 'e3');
|
||||||
|
|
||||||
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
expect(Object.keys(collection.collection.value)).toHaveLength(1);
|
||||||
expect(collection.collection.value).toHaveProperty('e2');
|
expect(collection.collection.value).toHaveProperty('e2');
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue