diff --git a/README.md b/README.md index 1585c9f..eb65ff1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/). +Build turn-based board games with reactive state, entity collections, spatial regions, and a command-driven game loop. + ## Features - **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/) -- **Type Safe**: Full TypeScript support with strict mode - **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.) - **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling - **Command Parsing**: CLI-style command parsing with schema validation and type coercion @@ -18,107 +19,173 @@ A state management library for board games using [Preact Signals](https://preact npm install boardgame-core ``` -## Usage +## Writing a Game -### Game Context +The core pattern for writing a game is: + +1. Define your game state type +2. Create a command registry with `createGameCommandRegistry()` +3. Register commands for `setup`, `turn`, and any actions +4. Run `setup` to start the game loop + +### Step 1: Define Your State + +Every game needs a state type. Use `RegionEntity` for boards/zones and plain fields for everything else. + +```ts +import { RegionEntity, Entity } from 'boardgame-core'; + +type Player = 'X' | 'O'; + +type GameState = { + board: RegionEntity; + parts: Entity[]; + currentPlayer: Player; + winner: Player | 'draw' | null; + turn: number; +}; +``` + +### Step 2: Create the Command Registry + +`createGameCommandRegistry` ties your state type to the command system. Commands are registered with a schema string and an async handler. + +```ts +import { createGameCommandRegistry } from 'boardgame-core'; + +const registration = createGameCommandRegistry(); +export const registry = registration.registry; +``` + +### Step 3: Register the `setup` Command + +The `setup` command is the main game loop. It runs turns until a winner is determined. + +```ts +registration.add('setup', async function() { + const { context } = this; + while (true) { + const currentPlayer = context.value.currentPlayer; + const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer}`); + 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++; + } + }); + if (context.value.winner) break; + } + return context.value; +}); +``` + +### Step 4: Register the `turn` Command + +The `turn` command handles a single player's turn. Use `this.prompt()` to request validated input from the player. + +```ts +registration.add('turn ', async function(cmd) { + const [turnPlayer] = cmd.params as [Player]; + + const playCmd = await this.prompt( + 'play ', + (command) => { + const [player, row, col] = command.params as [Player, number, number]; + if (player !== turnPlayer) return `Wrong player.`; + if (isCellOccupied(this.context, row, col)) return `Cell occupied.`; + return null; // null = valid + } + ); + const [, row, col] = playCmd.params as [Player, number, number]; + + placePiece(this.context, row, col, turnPlayer); + + const winner = checkWinner(this.context); + return { winner }; +}); +``` + +### Managing Part Movement + +Move parts between regions with `moveToRegion`, `moveToRegionAll`, and `removeFromRegion`. + +```ts +import { moveToRegion, moveToRegionAll, removeFromRegion } from 'boardgame-core'; + +// Move a single piece to a new region with a new position +moveToRegion(card, handRegion, [0]); + +// Move a single piece, keeping its current position +moveToRegion(card, handRegion); + +// Move multiple pieces at once with new positions +moveToRegionAll([card1, card2, card3], discardPile, [[0], [1], [2]]); + +// Remove a piece from its region (without adding to another) +removeFromRegion(card); +``` + +### Step 5: Manage Parts on the Board + +Parts are game pieces placed inside regions. Use `entity()` to create reactive entities and `produce()` to mutate state. + +```ts +import { entity } from 'boardgame-core'; + +function placePiece(host: Entity, row: number, col: number, player: Player) { + const board = host.value.board; + const piece = { + id: `piece-${player}-${host.value.parts.length + 1}`, + region: board, + position: [row, col], + player, + }; + host.produce(state => { + const e = entity(piece.id, piece); + state.parts.push(e); + board.produce(draft => { + draft.children.push(e); + }); + }); +} +``` + +### Step 6: Run the Game + +```ts +import { createGameContextFromModule } from 'boardgame-core'; +import * as yourGame from './your-game'; + +const game = createGameContextFromModule(yourGame); +game.commands.run('setup'); +``` + +Or use `createGameContext` directly: ```ts import { createGameContext } from 'boardgame-core'; +import { registry, createInitialState } from './your-game'; -const game = createGameContext({ type: 'game' }); - -// Access entity collections -game.parts.add({ id: 'card1', sides: 2, side: 0, region: /* ... */, position: [0] }); -game.regions.add({ id: 'hand', axes: [{ name: 'slot', min: 0, max: 7, align: 'start' }], children: [] }); - -// Context stack for nested rule scopes -game.pushContext({ type: 'combat' }); -const combatCtx = game.latestContext('combat'); -game.popContext(); +const game = createGameContext(registry, createInitialState); +game.commands.run('setup'); ``` -### Parts (Cards, Dice, Tokens) +## Sample Games -```ts -import { flip, flipTo, roll, createRNG } from 'boardgame-core'; +### Tic-Tac-Toe -const rng = createRNG(42); +The simplest example. Shows the basic command loop, 2D board regions, and win detection. -flip(card); // cycle to next side -flipTo(card, 0); // set to specific side -roll(dice, rng); // random side using seeded RNG -``` +See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts). -### Regions & Alignment +### Boop -```ts -import { applyAlign, shuffle } from 'boardgame-core'; +A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules. -// Compact cards in a hand towards the start -applyAlign(handRegion); - -// Shuffle positions of all parts in a region -shuffle(handRegion, rng); -``` - -### Command Parsing - -```ts -import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core'; - -// Parse a command string -const cmd = parseCommand('move card1 hand --force -x 10'); -// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } } - -// Define and validate against a schema -const schema = parseCommandSchema('move [--force] [-x: number]'); -const result = validateCommand(cmd, schema); -// { valid: true } -``` - -### Entity Collections - -```ts -import { createEntityCollection } from 'boardgame-core'; - -const collection = createEntityCollection(); -collection.add({ id: 'a', name: 'Item A' }, { id: 'b', name: 'Item B' }); - -const accessor = collection.get('a'); -console.log(accessor.value); // reactive access - -collection.remove('a'); -``` - -### Rule Engine - -```ts -import { createRule, invokeRuleContext } from 'boardgame-core'; - -const myRule = createRule('drawCard', (ctx) => { - // yield action types to pause and wait for external handling - const action = yield 'draw'; - ctx.resolution = action; -}); - -const result = invokeRuleContext( - game.pushContext.bind(game), - 'drawCard', - myRule({ type: 'drawCard', actions: [], handledActions: 0, invocations: [] }) -); -``` - -### Random Number Generation - -```ts -import { createRNG } from 'boardgame-core'; - -const rng = createRNG(12345); -rng.nextInt(6); // 0-5 -rng.next(); // [0, 1) -rng.next(100); // [0, 100) -rng.setSeed(999); // reseed -``` +See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](src/samples/boop/rules.md). ## API Reference @@ -127,14 +194,16 @@ rng.setSeed(999); // reseed | Export | Description | |---|---| | `createGameContext(root?)` | Create a new game context instance | +| `createGameCommandRegistry()` | Create a typed command registry for your game state | | `GameContext` | The game context model class | -| `Context` | Base context type | +| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management | ### Parts | Export | Description | |---|---| -| `Part` | Entity type representing a game piece (card, die, token, etc.) | +| `Part` | Entity type representing a game piece | +| `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 | @@ -143,18 +212,13 @@ rng.setSeed(999); // reseed | Export | Description | |---|---| -| `Region` | Entity type for spatial grouping of parts | +| `RegionEntity` | Entity type for spatial grouping of parts | | `RegionAxis` | Axis definition with min/max/align | | `applyAlign(region)` | Compact parts according to axis alignment | | `shuffle(region, rng)` | Randomize part positions | - -### Rules - -| Export | Description | -|---|---| -| `RuleContext` | Rule execution context type | -| `createRule(type, fn)` | Create a rule generator factory | -| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management | +| `moveToRegion(part, targetRegion, position?)` | Move a part to another region | +| `moveToRegionAll(parts, targetRegion, positions?)` | Move multiple parts to another region | +| `removeFromRegion(part)` | Remove a part from its region | ### Commands diff --git a/src/core/region.ts b/src/core/region.ts index 6951c9b..9713a20 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -113,4 +113,48 @@ function shuffleCore(region: Region, rng: RNG){ draft.position = posI; }); } +} + +export function moveToRegion(part: Entity, targetRegion: Entity, position?: number[]) { + const sourceRegion = part.value.region; + batch(() => { + sourceRegion.produce(draft => { + draft.children = draft.children.filter(c => c.id !== part.id); + }); + targetRegion.produce(draft => { + draft.children.push(part); + }); + part.produce(draft => { + draft.region = targetRegion; + if (position) draft.position = position; + }); + }); +} + +export function moveToRegionAll(parts: Entity[], targetRegion: Entity, positions?: number[][]) { + batch(() => { + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const sourceRegion = part.value.region; + sourceRegion.produce(draft => { + draft.children = draft.children.filter(c => c.id !== part.id); + }); + targetRegion.produce(draft => { + draft.children.push(part); + }); + part.produce(draft => { + draft.region = targetRegion; + if (positions && positions[i]) draft.position = positions[i]; + }); + } + }); +} + +export function removeFromRegion(part: Entity) { + const region = part.value.region; + batch(() => { + region.produce(draft => { + draft.children = draft.children.filter(c => c.id !== part.id); + }); + }); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e9095e4..fb3f086 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export type { Part } from './core/part'; export { flip, flipTo, roll } from './core/part'; export type { Region, RegionAxis } from './core/region'; -export { applyAlign, shuffle, RegionEntity } from './core/region'; +export { applyAlign, shuffle, RegionEntity, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region'; // Utils export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command'; diff --git a/tests/core/region.test.ts b/tests/core/region.test.ts index c9c0bb8..214e19e 100644 --- a/tests/core/region.test.ts +++ b/tests/core/region.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyAlign, shuffle, type Region, type RegionAxis } from '@/core/region'; +import { applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region'; import { createRNG } from '@/utils/rng'; import { entity, Entity } from '@/utils/entity'; import { type Part } from '@/core/part'; @@ -273,4 +273,109 @@ describe('Region', () => { expect(results.size).toBeGreaterThan(5); }); }); + + describe('moveToRegion', () => { + it('should move a part from one region to another', () => { + const sourceAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }]; + const targetAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }]; + const sourceRegion = createTestRegion(sourceAxes, []); + const targetRegion = createTestRegion(targetAxes, []); + + const part: Part = { id: 'p1', region: sourceRegion, position: [2] }; + const partEntity = entity(part.id, part); + sourceRegion.value.children.push(partEntity); + + expect(sourceRegion.value.children).toHaveLength(1); + expect(targetRegion.value.children).toHaveLength(0); + expect(partEntity.value.region.value.id).toBe('region1'); + + moveToRegion(partEntity, targetRegion, [0]); + + expect(sourceRegion.value.children).toHaveLength(0); + expect(targetRegion.value.children).toHaveLength(1); + expect(partEntity.value.region.value.id).toBe('region1'); + expect(partEntity.value.position).toEqual([0]); + }); + + it('should keep existing position if no position provided', () => { + const sourceRegion = createTestRegion([{ name: 'x' }], []); + const targetRegion = createTestRegion([{ name: 'x' }], []); + + const part: Part = { id: 'p1', region: sourceRegion, position: [3] }; + const partEntity = entity(part.id, part); + sourceRegion.value.children.push(partEntity); + + moveToRegion(partEntity, targetRegion); + + expect(partEntity.value.position).toEqual([3]); + }); + }); + + describe('moveToRegionAll', () => { + it('should move multiple parts to a target region', () => { + const sourceRegion = createTestRegion([{ name: 'x' }], []); + const targetRegion = createTestRegion([{ name: 'x' }], []); + + const parts = [ + entity('p1', { id: 'p1', region: sourceRegion, position: [0] } as Part), + entity('p2', { id: 'p2', region: sourceRegion, position: [1] } as Part), + entity('p3', { id: 'p3', region: sourceRegion, position: [2] } as Part), + ]; + sourceRegion.value.children.push(...parts); + + moveToRegionAll(parts, targetRegion, [[0], [1], [2]]); + + expect(sourceRegion.value.children).toHaveLength(0); + expect(targetRegion.value.children).toHaveLength(3); + expect(parts[0].value.position).toEqual([0]); + expect(parts[1].value.position).toEqual([1]); + expect(parts[2].value.position).toEqual([2]); + }); + + it('should keep existing positions if no positions provided', () => { + const sourceRegion = createTestRegion([{ name: 'x' }], []); + const targetRegion = createTestRegion([{ name: 'x' }], []); + + const parts = [ + entity('p1', { id: 'p1', region: sourceRegion, position: [5] } as Part), + entity('p2', { id: 'p2', region: sourceRegion, position: [8] } as Part), + ]; + sourceRegion.value.children.push(...parts); + + moveToRegionAll(parts, targetRegion); + + expect(parts[0].value.position).toEqual([5]); + expect(parts[1].value.position).toEqual([8]); + }); + }); + + describe('removeFromRegion', () => { + it('should remove a part from its region', () => { + const region = createTestRegion([{ name: 'x' }], []); + + const part: Part = { id: 'p1', region: region, position: [2] }; + const partEntity = entity(part.id, part); + region.value.children.push(partEntity); + + expect(region.value.children).toHaveLength(1); + + removeFromRegion(partEntity); + + expect(region.value.children).toHaveLength(0); + }); + + it('should leave other parts unaffected', () => { + const region = createTestRegion([{ name: 'x' }], []); + + const p1 = entity('p1', { id: 'p1', region: region, position: [0] } as Part); + const p2 = entity('p2', { id: 'p2', region: region, position: [1] } as Part); + const p3 = entity('p3', { id: 'p3', region: region, position: [2] } as Part); + region.value.children.push(p1, p2, p3); + + removeFromRegion(p2); + + expect(region.value.children).toHaveLength(2); + expect(region.value.children.map(c => c.value.id)).toEqual(['p1', 'p3']); + }); + }); });