From 4bf6eb2f6bc738d6af5447da927086018a6190c7 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 3 Apr 2026 15:00:25 +0800 Subject: [PATCH] refactor: add some part management utilities --- AGENTS.md | 16 +++--- README.md | 16 ++++-- src/core/part-factory.ts | 88 +++++++++++++++++++++++++++++++ src/core/part.ts | 24 +++++++-- src/core/region.ts | 24 +++++---- src/index.ts | 5 +- src/samples/boop/index.ts | 23 +++----- src/samples/tic-tac-toe.ts | 25 ++++----- tests/samples/tic-tac-toe.test.ts | 12 ++--- 9 files changed, 170 insertions(+), 63 deletions(-) create mode 100644 src/core/part-factory.ts diff --git a/AGENTS.md b/AGENTS.md index 4898c68..daa3417 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ npx vitest run -t "should detect horizontal win for X" src/ core/ # Core game primitives (game, part, region) samples/ # Example games (tic-tac-toe, boop) - utils/ # Shared utilities (entity, command, rng, async-queue) + utils/ # Shared utilities (mutable-signal, command, rng, async-queue) index.ts # Single public API barrel export tests/ # Mirrors src/ structure with *.test.ts files ``` @@ -49,16 +49,16 @@ tests/ # Mirrors src/ structure with *.test.ts files ### Naming Conventions - **Types/Interfaces**: `PascalCase` — `Part`, `Region`, `Command`, `IGameContext` -- **Classes**: `PascalCase` — `Entity`, `AsyncQueue`, `Mulberry32RNG` +- **Classes**: `PascalCase` — `MutableSignal`, `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/` -- **Factory functions**: prefix with `create` — `createEntity`, `createTestContext` +- **Factory functions**: prefix with `create` or `mutable` — `createGameContext`, `mutableSignal` ### Types - **Strict TypeScript** is enabled — no `any` -- Use **generics** heavily: `Entity`, `CommandRunner` +- Use **generics** heavily: `MutableSignal`, `CommandRunner` - 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` @@ -82,10 +82,10 @@ tests/ # Mirrors src/ structure with *.test.ts files ## Architecture Notes -- **Reactivity**: `Entity` extends Preact Signal — access state via `.value`, mutate via `.produce(draft => ...)` +- **Reactivity**: `MutableSignal` 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` +- **Prompt system**: Commands prompt for input via `PromptEvent` with `resolve`/`reject`; `tryCommit` accepts `Command | string` and validates against schema before custom validator - **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()` +- **Region system**: plain `Region` type with `createRegion()` factory; `parts` are a separate record keyed by ID +- **Mutative**: used for immutable state updates inside `MutableSignal.produce()` diff --git a/README.md b/README.md index 6c9709c..350b6b8 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,16 @@ rng.setSeed(999); // reseed | Export | Description | |---|---| -| `Part` | Type representing a game piece with sides, position, and region | -| `entity(id, data)` | Create a reactive `Entity` | +| `Part` | Type representing a game piece with sides, position, and region. `TMeta` for game-specific fields | +| `PartTemplate` | Template type for creating parts (excludes `id`, requires metadata) | +| `PartPool` | Pool of parts with `draw()`, `return()`, and `remaining()` methods | +| `createPart(template, id)` | Create a single part from a template | +| `createParts(template, count, idPrefix)` | Create multiple identical parts with auto-generated IDs | +| `createPartPool(template, count, idPrefix)` | Create a pool of parts for lazy loading | +| `mergePartPools(...pools)` | Merge multiple part pools into one | +| `findPartById(parts, id)` | Find a part by ID in an array | +| `isCellOccupied(parts, regionId, position)` | Check if a cell is occupied | +| `getPartAtPosition(parts, regionId, position)` | Get the part at a specific position | | `flip(part)` | Cycle to the next side | | `flipTo(part, side)` | Set to a specific side | | `roll(part, rng)` | Randomize side using RNG | @@ -209,8 +217,8 @@ rng.setSeed(999); // reseed | `createRegion(id, axes)` | Create a new region | | `applyAlign(region, parts)` | Compact parts according to axis alignment | | `shuffle(region, parts, rng)` | Randomize part positions | -| `moveToRegion(part, sourceRegion, targetRegion, position?)` | Move a part to another region | -| `moveToRegionAll(parts, sourceRegion, targetRegion, positions?)` | Move multiple parts to another region | +| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | Move a part to another region. `sourceRegion` is optional for first placement | +| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `sourceRegion` is optional for first placement | | `removeFromRegion(part, region)` | Remove a part from its region | ### Commands diff --git a/src/core/part-factory.ts b/src/core/part-factory.ts new file mode 100644 index 0000000..4d782e8 --- /dev/null +++ b/src/core/part-factory.ts @@ -0,0 +1,88 @@ +import {Part} from "./part"; + +export type PartTemplate = Omit>, 'id'> & TMeta; + +export type PartPool = { + parts: Part[]; + template: PartTemplate; + draw(): Part | undefined; + return(part: Part): void; + remaining(): number; +}; + +export function createPart( + template: PartTemplate, + id: string +): Part { + return { + regionId: '', + position: [], + ...template, + id, + } as Part; +} + +export function createParts( + template: PartTemplate, + count: number, + idPrefix: string +): Part[] { + const parts: Part[] = []; + for (let i = 0; i < count; i++) { + parts.push(createPart(template, `${idPrefix}-${i + 1}`)); + } + return parts; +} + +export function createPartPool( + template: PartTemplate, + count: number, + idPrefix: string +): PartPool { + const parts = createParts(template, count, idPrefix); + const available = [...parts]; + + return { + parts, + template, + draw() { + return available.pop(); + }, + return(part: Part) { + part.regionId = ''; + part.position = []; + available.push(part); + }, + remaining() { + return available.length; + }, + }; +} + +export function mergePartPools( + ...pools: PartPool[] +): PartPool { + if (pools.length === 0) { + return createPartPool({} as PartTemplate, 0, 'merged'); + } + + const allParts = pools.flatMap(p => p.parts); + const template = pools[0].template; + const available = allParts.filter(p => p.regionId === ''); + + return { + parts: allParts, + template, + draw() { + return available.pop(); + }, + return(part: Part) { + part.regionId = ''; + part.position = []; + available.push(part); + }, + remaining() { + return available.length; + }, + }; +} diff --git a/src/core/part.ts b/src/core/part.ts index 962fca7..4a799ed 100644 --- a/src/core/part.ts +++ b/src/core/part.ts @@ -1,6 +1,6 @@ import {RNG} from "@/utils/rng"; -export type Part = { +export type Part = { id: string; sides?: number; @@ -10,19 +10,33 @@ export type Part = { alignment?: string; regionId: string; position: number[]; -} +} & TMeta; -export function flip(part: Part) { +export function flip(part: Part) { if(!part.sides) return; part.side = ((part.side || 0) + 1) % part.sides; } -export function flipTo(part: Part, side: number) { +export function flipTo(part: Part, side: number) { if(!part.sides || side >= part.sides) return; part.side = side; } -export function roll(part: Part, rng: RNG) { +export function roll(part: Part, rng: RNG) { if(!part.sides) return; part.side = rng.nextInt(part.sides); } + +export function findPartById(parts: Part[], id: string): Part | undefined { + return parts.find(p => p.id === id); +} + +export function isCellOccupied(parts: Part[], regionId: string, position: number[]): boolean { + const posKey = position.join(','); + return parts.some(p => p.regionId === regionId && p.position.join(',') === posKey); +} + +export function getPartAtPosition(parts: Part[], regionId: string, position: number[]): Part | undefined { + const posKey = position.join(','); + return parts.find(p => p.regionId === regionId && p.position.join(',') === posKey); +} diff --git a/src/core/region.ts b/src/core/region.ts index 45c3fd1..ac01662 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -24,7 +24,7 @@ export function createRegion(id: string, axes: RegionAxis[]): Region { }; } -function buildPartMap(region: Region, parts: Record) { +function buildPartMap(region: Region, parts: Record>) { const map: Record = {}; for (const childId of region.childIds) { const part = parts[childId]; @@ -35,7 +35,7 @@ function buildPartMap(region: Region, parts: Record) { return map; } -export function applyAlign(region: Region, parts: Record) { +export function applyAlign(region: Region, parts: Record>) { if (region.childIds.length === 0) return; for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) { @@ -98,7 +98,7 @@ export function applyAlign(region: Region, parts: Record) { region.partMap = buildPartMap(region, parts); } -export function shuffle(region: Region, parts: Record, rng: RNG){ +export function shuffle(region: Region, parts: Record>, rng: RNG){ if (region.childIds.length <= 1) return; const childIds = [...region.childIds]; @@ -117,9 +117,11 @@ export function shuffle(region: Region, parts: Record, rng: RNG){ region.partMap = buildPartMap(region, parts); } -export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Region, position?: number[]) { - sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id); - delete sourceRegion.partMap[part.position.join(',')]; +export function moveToRegion(part: Part, sourceRegion: Region | null, targetRegion: Region, position?: number[]) { + if (sourceRegion && part.regionId === sourceRegion.id) { + sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id); + delete sourceRegion.partMap[part.position.join(',')]; + } targetRegion.childIds.push(part.id); if (position) { @@ -130,11 +132,13 @@ export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Reg part.regionId = targetRegion.id; } -export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegion: Region, positions?: number[][]) { +export function moveToRegionAll(parts: Part[], sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) { for (let i = 0; i < parts.length; i++) { const part = parts[i]; - sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id); - delete sourceRegion.partMap[part.position.join(',')]; + if (sourceRegion && part.regionId === sourceRegion.id) { + sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id); + delete sourceRegion.partMap[part.position.join(',')]; + } targetRegion.childIds.push(part.id); if (positions && positions[i]) { @@ -146,7 +150,7 @@ export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegio } } -export function removeFromRegion(part: Part, region: Region) { +export function removeFromRegion(part: Part, region: Region) { region.childIds = region.childIds.filter(id => id !== part.id); delete region.partMap[part.position.join(',')]; } diff --git a/src/index.ts b/src/index.ts index b42b63c..822ccc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,10 @@ export type { IGameContext } from './core/game'; export { createGameContext, createGameCommandRegistry } from './core/game'; export type { Part } from './core/part'; -export { flip, flipTo, roll } from './core/part'; +export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition } from './core/part'; + +export type { PartTemplate, PartPool } from './core/part-factory'; +export { createPart, createParts, createPartPool, mergePartPools } from './core/part-factory'; export type { Region, RegionAxis } from './core/region'; export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region'; diff --git a/src/samples/boop/index.ts b/src/samples/boop/index.ts index 3ed793a..05e54eb 100644 --- a/src/samples/boop/index.ts +++ b/src/samples/boop/index.ts @@ -1,4 +1,4 @@ -import {createGameCommandRegistry, Part, MutableSignal, createRegion} from '@/index'; +import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil, getPartAtPosition} from '@/index'; const BOARD_SIZE = 6; const MAX_PIECES_PER_PLAYER = 8; @@ -8,7 +8,7 @@ export type PlayerType = 'white' | 'black'; export type PieceType = 'kitten' | 'cat'; export type WinnerType = PlayerType | 'draw' | null; -type BoopPart = Part & { player: PlayerType; pieceType: PieceType }; +type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>; type PieceSupply = { supply: number; placed: number }; @@ -157,15 +157,11 @@ export function getBoardRegion(host: MutableSignal) { } export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { - const board = getBoardRegion(host); - return board.partMap[`${row},${col}`] !== undefined; + return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]); } export function getPartAt(host: MutableSignal, row: number, col: number): BoopPart | null { - const board = getBoardRegion(host); - const partId = board.partMap[`${row},${col}`]; - if (!partId) return null; - return host.value.pieces.find(p => p.id === partId) || null; + return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null; } export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType, pieceType: PieceType) { @@ -173,13 +169,10 @@ export function placePiece(host: MutableSignal, row: number, col: num const playerData = getPlayer(host, player); const count = playerData[pieceType].placed + 1; - const piece: BoopPart = { - id: `${player}-${pieceType}-${count}`, - regionId: 'board', - position: [row, col], - player, - pieceType, - }; + const piece = createPart<{ player: PlayerType; pieceType: PieceType }>( + { regionId: 'board', position: [row, col], player, pieceType }, + `${player}-${pieceType}-${count}` + ); host.produce(s => { s.pieces.push(piece); board.childIds.push(piece.id); diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 76e1b93..69c160a 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,4 +1,4 @@ -import {createGameCommandRegistry, Part, MutableSignal, createRegion, moveToRegion} from '@/index'; +import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil} from '@/index'; const BOARD_SIZE = 3; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; @@ -16,7 +16,7 @@ const WINNING_LINES: number[][][] = [ export type PlayerType = 'X' | 'O'; export type WinnerType = PlayerType | 'draw' | null; -type TicTacToePart = Part & { player: PlayerType }; +type TicTacToePart = Part<{ player: PlayerType }>; export function createInitialState() { return { @@ -24,7 +24,7 @@ export function createInitialState() { { name: 'x', min: 0, max: BOARD_SIZE - 1 }, { name: 'y', min: 0, max: BOARD_SIZE - 1 }, ]), - parts: {} as Record, + parts: [] as TicTacToePart[], currentPlayer: 'X' as PlayerType, winner: null as WinnerType, turn: 0, @@ -91,8 +91,7 @@ function isValidMove(row: number, col: number): boolean { } export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { - const board = host.value.board; - return board.partMap[`${row},${col}`] !== undefined; + return isCellOccupiedUtil(host.value.parts, 'board', [row, col]); } export function hasWinningLine(positions: number[][]): boolean { @@ -104,7 +103,7 @@ export function hasWinningLine(positions: number[][]): boolean { } export function checkWinner(host: MutableSignal): WinnerType { - const parts = Object.values(host.value.parts); + const parts = host.value.parts; const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position); @@ -118,15 +117,13 @@ export function checkWinner(host: MutableSignal): WinnerType { export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType) { const board = host.value.board; - const moveNumber = Object.keys(host.value.parts).length + 1; - const piece: TicTacToePart = { - id: `piece-${player}-${moveNumber}`, - regionId: 'board', - position: [row, col], - player, - }; + const moveNumber = host.value.parts.length + 1; + const piece = createPart<{ player: PlayerType }>( + { regionId: 'board', position: [row, col], player }, + `piece-${player}-${moveNumber}` + ); host.produce(state => { - state.parts[piece.id] = piece; + state.parts.push(piece); board.childIds.push(piece.id); board.partMap[`${row},${col}`] = piece.id; }); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts index e54ab2c..48da2cd 100644 --- a/tests/samples/tic-tac-toe.test.ts +++ b/tests/samples/tic-tac-toe.test.ts @@ -163,9 +163,9 @@ describe('TicTacToe - helper functions', () => { const state = getState(ctx); placePiece(state, 1, 1, 'X'); - expect(Object.keys(state.value.parts).length).toBe(1); - expect(state.value.parts['piece-X-1'].position).toEqual([1, 1]); - expect(state.value.parts['piece-X-1'].player).toBe('X'); + expect(state.value.parts.length).toBe(1); + expect(state.value.parts.find(p => p.id === 'piece-X-1')!.position).toEqual([1, 1]); + expect(state.value.parts.find(p => p.id === 'piece-X-1')!.player).toBe('X'); }); it('should add piece to board region children', () => { @@ -183,7 +183,7 @@ describe('TicTacToe - helper functions', () => { placePiece(state, 0, 0, 'X'); placePiece(state, 0, 1, 'O'); - const ids = Object.keys(state.value.parts); + const ids = state.value.parts.map(p => p.id); expect(new Set(ids).size).toBe(2); }); }); @@ -229,8 +229,8 @@ describe('TicTacToe - game flow', () => { const result = await runPromise; expect(result.success).toBe(true); if (result.success) expect(result.result.winner).toBeNull(); - expect(Object.keys(ctx.state.value.parts).length).toBe(1); - expect(ctx.state.value.parts['piece-X-1'].position).toEqual([1, 1]); + expect(ctx.state.value.parts.length).toBe(1); + expect(ctx.state.value.parts.find(p => p.id === 'piece-X-1')!.position).toEqual([1, 1]); }); it('should reject move for wrong player and re-prompt', async () => {