diff --git a/AGENTS.md b/AGENTS.md index 2801de4..4898c68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ 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 build # Build ESM bundle + declarations to dist/ (via tsup) 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) @@ -21,18 +21,31 @@ npx vitest run tests/samples/tic-tac-toe.test.ts npx vitest run -t "should detect horizontal win for X" ``` +## Project Structure + +``` +src/ + core/ # Core game primitives (game, part, region) + samples/ # Example games (tic-tac-toe, boop) + utils/ # Shared utilities (entity, command, rng, async-queue) + index.ts # Single public API barrel export +tests/ # Mirrors src/ structure with *.test.ts files +``` + ## Code Style ### Imports - Use **double quotes** for local imports, **single quotes** for npm packages -- Use `@/**/*` for `./src/**/*` import alias -- Use `@/index` for code in `samples` +- Use `@/**/*` for `./src/**/*` import alias (configured in tsconfig.json) +- Use `@/index` when importing from the public API surface in samples +- Group imports: npm packages first, then local `@/` imports, separated by blank line ### Formatting -- **4-space indentation** +- **4-space indentation** (no tabs) - **Semicolons** at statement ends - **Arrow functions** for callbacks; `function` keyword for methods needing `this` (e.g. command handlers) - No trailing whitespace +- Trailing commas in multi-line objects/arrays ### Naming Conventions - **Types/Interfaces**: `PascalCase` — `Part`, `Region`, `Command`, `IGameContext` @@ -41,6 +54,7 @@ npx vitest run -t "should detect horizontal win for X" - **Variables**: `camelCase` - **Constants**: `UPPER_SNAKE_CASE` — `BOARD_SIZE`, `WINNING_LINES` - **Test files**: `*.test.ts` mirroring `src/` structure under `tests/` +- **Factory functions**: prefix with `create` — `createEntity`, `createTestContext` ### Types - **Strict TypeScript** is enabled — no `any` @@ -48,19 +62,22 @@ npx vitest run -t "should detect horizontal win for X" - 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` +- Derive state types via `ReturnType` ### 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. `"参数不足"`) +- Prompt validators return `null` for valid input, `string` error message for invalid ### 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 +- Define inline helper functions in test files when needed (e.g. `createTestContext()`) - No mocking — use real implementations +- For command tests: use `waitForPrompt()` + `promptEvent.tryCommit()` pattern - Test helpers: `createTestContext()`, `createTestRegion()` ## Architecture Notes @@ -69,3 +86,6 @@ npx vitest run -t "should detect horizontal win for X" - **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 +- **Game modules**: export `registry` and `createInitialState` for use with `createGameContextFromModule` +- **RegionEntity**: manages spatial board state with axis-based positioning and child entities +- **Mutative**: used for immutable state updates inside `Entity.produce()` diff --git a/README.md b/README.md index 0d8a696..f8ccafd 100644 --- a/README.md +++ b/README.md @@ -23,63 +23,113 @@ npm install boardgame-core ### Defining a Game -Each game defines its own context type by extending `IGameContext`, creates a command registry, and exports helper functions: +Each game defines a command registry and exports a `createInitialState` function: ```ts -import { IGameContext, createGameCommand, createGameContext, createCommandRegistry, registerCommand } from 'boardgame-core'; +import { + createGameCommandRegistry, + RegionEntity, + Entity, +} from 'boardgame-core'; // 1. Define your game-specific state type MyGameState = { - score: number; - round: number; + board: RegionEntity; + score: { white: number; black: number }; + currentPlayer: 'white' | 'black'; + winner: 'white' | 'black' | 'draw' | null; }; -// 2. Extend IGameContext with your state -type MyGameContext = IGameContext & { - state: MyGameState; -}; - -// 3. Define typed commands -const addScore = createGameCommand( - 'add-score ', - async function(cmd) { - this.context.state.score += cmd.params[0] as number; - return this.context.state.score; - } -); - -// 4. Create and populate a registry -const registry = createCommandRegistry(); -registerCommand(registry, addScore); - -// 5. Export a context factory with initial state -export function createMyGameContext() { - return createGameContext(registry, () => ({ - state: { score: 0, round: 1 }, - })); +// 2. Create initial state factory +export function createInitialState(): MyGameState { + return { + board: new RegionEntity('board', { + id: 'board', + axes: [ + { name: 'x', min: 0, max: 5 }, + { name: 'y', min: 0, max: 5 }, + ], + children: [], + }), + score: { white: 0, black: 0 }, + currentPlayer: 'white', + winner: null, + }; } -// 6. Export helper functions for your game logic -export function getScore(ctx: MyGameContext) { - return ctx.state.score; -} +// 3. Create registry and register commands +const registration = createGameCommandRegistry(); +export const registry = registration.registry; + +registration.add('place ', async function(cmd) { + const [row, col] = cmd.params as [number, number]; + const player = this.context.value.currentPlayer; + + // Mutate state via produce() + this.context.produce(state => { + state.score[player] += 1; + }); + + return { success: true }; +}); ``` ### Running a Game ```ts -import { createMyGameContext } from './my-game'; +import { createGameContext } from 'boardgame-core'; +import { registry, createInitialState } from './my-game'; -const game = createMyGameContext(); +const game = createGameContext(registry, createInitialState); // Run commands through the context -const result = await game.commands.run('add-score 10'); +const result = await game.commands.run('place 2 3'); if (result.success) { - console.log(result.result); // 10 + console.log(result.result); } -// Access game state -console.log(game.state.score); // 10 +// Access reactive game state +console.log(game.state.value.score.white); +``` + +### Handling Player Input + +Commands can prompt for player input using `this.prompt()`. Use `promptQueue.pop()` to wait for prompt events: + +```ts +import { createGameContext } from 'boardgame-core'; +import { registry, createInitialState } from './my-game'; + +const game = createGameContext(registry, createInitialState); + +// Start a command that will prompt for input +const runPromise = game.commands.run('turn X 1'); + +// Wait for the prompt event +const promptEvent = await game.commands.promptQueue.pop(); +console.log(promptEvent.schema.name); // e.g. 'play' + +// Validate and submit player input +const error = promptEvent.tryCommit({ + name: 'play', + params: ['X', 1, 1], + options: {}, + flags: {}, +}); + +if (error) { + console.log('Invalid move:', error); + // tryCommit can be called again with corrected input +} else { + // Input accepted, command continues + const result = await runPromise; +} +``` + +If the player needs to cancel instead of committing: + +```ts +promptEvent.cancel('player quit'); ``` ## Sample Games @@ -94,6 +144,13 @@ See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts). A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules. +See [`src/samples/boop/index.ts`](src/samples/boop/index.ts). + +## Region System + +```ts +import { RegionEntity, applyAlign, shuffle } from 'boardgame-core'; + // Compact cards in a hand towards the start applyAlign(handRegion); @@ -101,7 +158,7 @@ applyAlign(handRegion); shuffle(handRegion, rng); ``` -### Command Parsing +## Command Parsing ```ts import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core'; @@ -116,7 +173,7 @@ const result = validateCommand(cmd, schema); // { valid: true } ``` -### Entity Collections +## Entity Collections ```ts import { createEntityCollection } from 'boardgame-core'; @@ -130,7 +187,7 @@ console.log(accessor.value); // reactive access collection.remove('a'); ``` -### Random Number Generation +## Random Number Generation ```ts import { createRNG } from 'boardgame-core'; @@ -148,16 +205,16 @@ rng.setSeed(999); // reseed | Export | Description | |---|---| -| `IGameContext` | Base interface for the game context (parts, regions, commands, prompts) | -| `createGameContext(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function for custom properties | -| `createGameCommand(schema, handler)` | Create a typed command with access to `this.context` | +| `IGameContext` | Base interface for the game context (state, commands) | +| `createGameContext(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function | +| `createGameCommandRegistry()` | Create a command registry with fluent `.add()` API | ### Parts | Export | Description | |---|---| -| `Part` | Entity type representing a game piece | -| `entity(id, data)` | Create a reactive entity | +| `Part` | Type representing a game piece with sides, position, and region | +| `entity(id, data)` | Create a reactive `Entity` | | `flip(part)` | Cycle to the next side | | `flipTo(part, side)` | Set to a specific side | | `roll(part, rng)` | Randomize side using RNG | @@ -166,7 +223,7 @@ rng.setSeed(999); // reseed | Export | Description | |---|---| -| `RegionEntity` | Entity type for spatial grouping of parts | +| `RegionEntity` | Entity type for spatial grouping of parts with axis-based positioning | | `RegionAxis` | Axis definition with min/max/align | | `applyAlign(region)` | Compact parts according to axis alignment | | `shuffle(region, rng)` | Randomize part positions | @@ -191,8 +248,6 @@ rng.setSeed(999); // reseed | `runCommand(registry, context, input)` | Parse and run a command string | | `runCommandParsed(registry, context, command)` | Run a pre-parsed command | | `createCommandRunnerContext(registry, context)` | Create a command runner context | -| `CommandRunner` | Command runner type with schema and run function | -| `CommandRunnerContext` | Context available inside command handlers | | `PromptEvent` | Event dispatched when a command prompts for input | ### Utilities @@ -206,7 +261,7 @@ rng.setSeed(999); // reseed ## Scripts ```bash -npm run build # Build with tsup +npm run build # Build ESM bundle + declarations to dist/ npm run test # Run tests in watch mode npm run test:run # Run tests once npm run typecheck # Type check with TypeScript