From 65a3d682b67f44dfea0dfb76a7ceed79a6e51828 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 3 Apr 2026 17:36:25 +0800 Subject: [PATCH] refactor: Part[] -> Record --- AGENTS.md | 3 ++- README.md | 6 +++--- src/core/part-factory.ts | 18 +++++++++++++----- src/core/part.ts | 12 ++++++------ src/core/region.ts | 7 ++++--- src/samples/boop/index.ts | 25 +++++++++++++++---------- src/samples/tic-tac-toe.ts | 13 +++++++------ tests/samples/boop.test.ts | 2 +- tests/samples/tic-tac-toe.test.ts | 12 ++++++------ 9 files changed, 57 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daa3417..06fda2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,5 +87,6 @@ tests/ # Mirrors src/ structure with *.test.ts files - **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` -- **Region system**: plain `Region` type with `createRegion()` factory; `parts` are a separate record keyed by ID +- **Region system**: plain `Region` type with `createRegion()` factory; `parts` are stored as `Record` keyed by ID, with `partMap` in regions mapping position keys to part IDs +- **Part collections**: Game state uses `Record>` (not arrays) for O(1) lookup by ID. Use `Object.values(parts)` when iteration is needed, `Object.keys(parts)` for count/IDs - **Mutative**: used for immutable state updates inside `MutableSignal.produce()` diff --git a/README.md b/README.md index 350b6b8..5f0d1ab 100644 --- a/README.md +++ b/README.md @@ -196,12 +196,12 @@ rng.setSeed(999); // reseed |---|---| | `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 | +| `PartPool` | Pool of parts with `draw()`, `return()`, and `remaining()` methods. `parts` field is `Record` | | `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 | +| `findPartById(parts, id)` | Find a part by ID in a Record | | `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 | @@ -218,7 +218,7 @@ rng.setSeed(999); // reseed | `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. `sourceRegion` is optional for first placement | -| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `sourceRegion` is optional for first placement | +| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `parts` is `Record`. `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 index 4d782e8..37cb47c 100644 --- a/src/core/part-factory.ts +++ b/src/core/part-factory.ts @@ -3,7 +3,7 @@ import {Part} from "./part"; export type PartTemplate = Omit>, 'id'> & TMeta; export type PartPool = { - parts: Part[]; + parts: Record>; template: PartTemplate; draw(): Part | undefined; return(part: Part): void; @@ -39,8 +39,12 @@ export function createPartPool( count: number, idPrefix: string ): PartPool { - const parts = createParts(template, count, idPrefix); - const available = [...parts]; + const partsArray = createParts(template, count, idPrefix); + const parts: Record> = {}; + for (const part of partsArray) { + parts[part.id] = part; + } + const available = [...partsArray]; return { parts, @@ -66,9 +70,13 @@ export function mergePartPools( return createPartPool({} as PartTemplate, 0, 'merged'); } - const allParts = pools.flatMap(p => p.parts); + const allPartsArray = pools.flatMap(p => Object.values(p.parts)); + const allParts: Record> = {}; + for (const part of allPartsArray) { + allParts[part.id] = part; + } const template = pools[0].template; - const available = allParts.filter(p => p.regionId === ''); + const available = allPartsArray.filter(p => p.regionId === ''); return { parts: allParts, diff --git a/src/core/part.ts b/src/core/part.ts index 4a799ed..8db9d11 100644 --- a/src/core/part.ts +++ b/src/core/part.ts @@ -27,16 +27,16 @@ export function roll(part: Part, rng: RNG) { part.side = rng.nextInt(part.sides); } -export function findPartById(parts: Part[], id: string): Part | undefined { - return parts.find(p => p.id === id); +export function findPartById(parts: Record>, id: string): Part | undefined { + return parts[id]; } -export function isCellOccupied(parts: Part[], regionId: string, position: number[]): boolean { +export function isCellOccupied(parts: Record>, regionId: string, position: number[]): boolean { const posKey = position.join(','); - return parts.some(p => p.regionId === regionId && p.position.join(',') === posKey); + return Object.values(parts).some(p => p.regionId === regionId && p.position.join(',') === posKey); } -export function getPartAtPosition(parts: Part[], regionId: string, position: number[]): Part | undefined { +export function getPartAtPosition(parts: Record>, regionId: string, position: number[]): Part | undefined { const posKey = position.join(','); - return parts.find(p => p.regionId === regionId && p.position.join(',') === posKey); + return Object.values(parts).find(p => p.regionId === regionId && p.position.join(',') === posKey); } diff --git a/src/core/region.ts b/src/core/region.ts index ac01662..5ca6cdc 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -132,9 +132,10 @@ export function moveToRegion(part: Part, sourceRegion: Region | nu part.regionId = targetRegion.id; } -export function moveToRegionAll(parts: Part[], sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) { - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; +export function moveToRegionAll(parts: Record>, sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) { + const partIds = Object.keys(parts); + for (let i = 0; i < partIds.length; i++) { + const part = parts[partIds[i]]; if (sourceRegion && part.regionId === sourceRegion.id) { sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id); delete sourceRegion.partMap[part.position.join(',')]; diff --git a/src/samples/boop/index.ts b/src/samples/boop/index.ts index 05e54eb..4ed20ff 100644 --- a/src/samples/boop/index.ts +++ b/src/samples/boop/index.ts @@ -26,7 +26,7 @@ export function createInitialState() { { name: 'x', min: 0, max: BOARD_SIZE - 1 }, { name: 'y', min: 0, max: BOARD_SIZE - 1 }, ]), - pieces: [] as BoopPart[], + pieces: {} as Record, currentPlayer: 'white' as PlayerType, winner: null as WinnerType, players: { @@ -119,7 +119,8 @@ registration.add('turn ', async function(cmd) { } if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) { - const availableKittens = this.context.value.pieces.filter( + const pieces = this.context.value.pieces; + const availableKittens = Object.values(pieces).filter( p => p.player === turnPlayer && p.pieceType === 'kitten' ); @@ -174,7 +175,7 @@ export function placePiece(host: MutableSignal, row: number, col: num `${player}-${pieceType}-${count}` ); host.produce(s => { - s.pieces.push(piece); + s.pieces[piece.id] = piece; board.childIds.push(piece.id); board.partMap[`${row},${col}`] = piece.id; }); @@ -184,10 +185,11 @@ export function placePiece(host: MutableSignal, row: number, col: num export function applyBoops(host: MutableSignal, placedRow: number, placedCol: number, placedType: PieceType) { const board = getBoardRegion(host); const pieces = host.value.pieces; + const piecesArray = Object.values(pieces); const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = []; - for (const part of pieces) { + for (const part of piecesArray) { const [r, c] = part.position; if (r === placedRow && c === placedCol) continue; @@ -223,7 +225,7 @@ export function applyBoops(host: MutableSignal, placedRow: number, pl part.position = [newRow, newCol]; board.partMap = Object.fromEntries( board.childIds.map(id => { - const p = pieces.find(x => x.id === id)!; + const p = pieces[id]; return [p.position.join(','), id]; }) ); @@ -235,7 +237,7 @@ export function removePieceFromBoard(host: MutableSignal, part: BoopP const playerData = getPlayer(host, part.player); board.childIds = board.childIds.filter(id => id !== part.id); delete board.partMap[part.position.join(',')]; - host.value.pieces = host.value.pieces.filter(p => p.id !== part.id); + delete host.value.pieces[part.id]; playerData[part.pieceType].placed--; } @@ -292,9 +294,10 @@ export function hasWinningLine(positions: number[][]): boolean { export function checkGraduation(host: MutableSignal, player: PlayerType): number[][][] { const pieces = host.value.pieces; + const piecesArray = Object.values(pieces); const posSet = new Set(); - for (const part of pieces) { + for (const part of piecesArray) { if (part.player === player && part.pieceType === 'kitten') { posSet.add(`${part.position[0]},${part.position[1]}`); } @@ -318,7 +321,8 @@ export function processGraduation(host: MutableSignal, player: Player } const board = getBoardRegion(host); - const partsToRemove = host.value.pieces.filter( + const pieces = host.value.pieces; + const partsToRemove = Object.values(pieces).filter( p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`) ); @@ -333,14 +337,15 @@ export function processGraduation(host: MutableSignal, player: Player export function countPiecesOnBoard(host: MutableSignal, player: PlayerType): number { const pieces = host.value.pieces; - return pieces.filter(p => p.player === player).length; + return Object.values(pieces).filter(p => p.player === player).length; } export function checkWinner(host: MutableSignal): WinnerType { const pieces = host.value.pieces; + const piecesArray = Object.values(pieces); for (const player of ['white', 'black'] as PlayerType[]) { - const positions = pieces + const positions = piecesArray .filter(p => p.player === player && p.pieceType === 'cat') .map(p => p.position); if (hasWinningLine(positions)) return player; diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 69c160a..d77c513 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -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 TicTacToePart[], + parts: {} as Record, currentPlayer: 'X' as PlayerType, winner: null as WinnerType, turn: 0, @@ -104,26 +104,27 @@ export function hasWinningLine(positions: number[][]): boolean { export function checkWinner(host: MutableSignal): WinnerType { const parts = host.value.parts; + const partsArray = Object.values(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); + const xPositions = partsArray.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position); + const oPositions = partsArray.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position); if (hasWinningLine(xPositions)) return 'X'; if (hasWinningLine(oPositions)) return 'O'; - if (parts.length >= MAX_TURNS) return 'draw'; + if (partsArray.length >= MAX_TURNS) return 'draw'; return null; } export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType) { const board = host.value.board; - const moveNumber = host.value.parts.length + 1; + const moveNumber = Object.keys(host.value.parts).length + 1; const piece = createPart<{ player: PlayerType }>( { regionId: 'board', position: [row, col], player }, `piece-${player}-${moveNumber}` ); host.produce(state => { - state.parts.push(piece); + state.parts[piece.id] = piece; board.childIds.push(piece.id); board.partMap[`${row},${col}`] = piece.id; }); diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index 5170e2b..f62cd39 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -36,7 +36,7 @@ function waitForPrompt(ctx: ReturnType['ctx']): Promis } function getParts(state: MutableSignal) { - return state.value.pieces; + return Object.values(state.value.pieces); } describe('Boop - helper functions', () => { diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts index 48da2cd..67e3805 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(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'); + 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'); }); 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 = state.value.parts.map(p => p.id); + const ids = Object.keys(state.value.parts); 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(ctx.state.value.parts.length).toBe(1); - expect(ctx.state.value.parts.find(p => p.id === 'piece-X-1')!.position).toEqual([1, 1]); + expect(Object.keys(ctx.state.value.parts).length).toBe(1); + expect(ctx.state.value.parts['piece-X-1']!.position).toEqual([1, 1]); }); it('should reject move for wrong player and re-prompt', async () => {