refactor: add some part management utilities
This commit is contained in:
parent
86714e7837
commit
4bf6eb2f6b
16
AGENTS.md
16
AGENTS.md
|
|
@ -27,7 +27,7 @@ npx vitest run -t "should detect horizontal win for X"
|
||||||
src/
|
src/
|
||||||
core/ # Core game primitives (game, part, region)
|
core/ # Core game primitives (game, part, region)
|
||||||
samples/ # Example games (tic-tac-toe, boop)
|
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
|
index.ts # Single public API barrel export
|
||||||
tests/ # Mirrors src/ structure with *.test.ts files
|
tests/ # Mirrors src/ structure with *.test.ts files
|
||||||
```
|
```
|
||||||
|
|
@ -49,16 +49,16 @@ tests/ # Mirrors src/ structure with *.test.ts files
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
- **Types/Interfaces**: `PascalCase` — `Part`, `Region`, `Command`, `IGameContext`
|
- **Types/Interfaces**: `PascalCase` — `Part`, `Region`, `Command`, `IGameContext`
|
||||||
- **Classes**: `PascalCase` — `Entity`, `AsyncQueue`, `Mulberry32RNG`
|
- **Classes**: `PascalCase` — `MutableSignal`, `AsyncQueue`, `Mulberry32RNG`
|
||||||
- **Functions**: `camelCase`, verb-first — `createGameContext`, `parseCommand`, `isValidMove`
|
- **Functions**: `camelCase`, verb-first — `createGameContext`, `parseCommand`, `isValidMove`
|
||||||
- **Variables**: `camelCase`
|
- **Variables**: `camelCase`
|
||||||
- **Constants**: `UPPER_SNAKE_CASE` — `BOARD_SIZE`, `WINNING_LINES`
|
- **Constants**: `UPPER_SNAKE_CASE` — `BOARD_SIZE`, `WINNING_LINES`
|
||||||
- **Test files**: `*.test.ts` mirroring `src/` structure under `tests/`
|
- **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
|
### Types
|
||||||
- **Strict TypeScript** is enabled — no `any`
|
- **Strict TypeScript** is enabled — no `any`
|
||||||
- Use **generics** heavily: `Entity<T>`, `CommandRunner<TContext, TResult>`
|
- Use **generics** heavily: `MutableSignal<T>`, `CommandRunner<TContext, TResult>`
|
||||||
- Use **type aliases** for object shapes (not interfaces)
|
- Use **type aliases** for object shapes (not interfaces)
|
||||||
- Use **discriminated unions** for results: `{ success: true; result: T } | { success: false; error: string }`
|
- Use **discriminated unions** for results: `{ success: true; result: T } | { success: false; error: string }`
|
||||||
- Use `unknown` for untyped values, narrow with type guards or `as`
|
- 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
|
## Architecture Notes
|
||||||
|
|
||||||
- **Reactivity**: `Entity<T>` extends Preact Signal — access state via `.value`, mutate via `.produce(draft => ...)`
|
- **Reactivity**: `MutableSignal<T>` extends Preact Signal — access state via `.value`, mutate via `.produce(draft => ...)`
|
||||||
- **Command system**: CLI-style parsing with schema validation via `inline-schema`
|
- **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
|
- **Barrel exports**: `src/index.ts` is the single public API surface
|
||||||
- **Game modules**: export `registry` and `createInitialState` for use with `createGameContextFromModule`
|
- **Game modules**: export `registry` and `createInitialState` for use with `createGameContextFromModule`
|
||||||
- **RegionEntity**: manages spatial board state with axis-based positioning and child entities
|
- **Region system**: plain `Region` type with `createRegion()` factory; `parts` are a separate record keyed by ID
|
||||||
- **Mutative**: used for immutable state updates inside `Entity.produce()`
|
- **Mutative**: used for immutable state updates inside `MutableSignal.produce()`
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -194,8 +194,16 @@ rng.setSeed(999); // reseed
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Part` | Type representing a game piece with sides, position, and region |
|
| `Part<TMeta>` | Type representing a game piece with sides, position, and region. `TMeta` for game-specific fields |
|
||||||
| `entity(id, data)` | Create a reactive `Entity<T>` |
|
| `PartTemplate<TMeta>` | Template type for creating parts (excludes `id`, requires metadata) |
|
||||||
|
| `PartPool<TMeta>` | 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 |
|
| `flip(part)` | Cycle to the next side |
|
||||||
| `flipTo(part, side)` | Set to a specific side |
|
| `flipTo(part, side)` | Set to a specific side |
|
||||||
| `roll(part, rng)` | Randomize side using RNG |
|
| `roll(part, rng)` | Randomize side using RNG |
|
||||||
|
|
@ -209,8 +217,8 @@ rng.setSeed(999); // reseed
|
||||||
| `createRegion(id, axes)` | Create a new region |
|
| `createRegion(id, axes)` | Create a new region |
|
||||||
| `applyAlign(region, parts)` | Compact parts according to axis alignment |
|
| `applyAlign(region, parts)` | Compact parts according to axis alignment |
|
||||||
| `shuffle(region, parts, rng)` | Randomize part positions |
|
| `shuffle(region, parts, rng)` | Randomize part positions |
|
||||||
| `moveToRegion(part, sourceRegion, targetRegion, position?)` | Move a part 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 |
|
| `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 |
|
| `removeFromRegion(part, region)` | Remove a part from its region |
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import {Part} from "./part";
|
||||||
|
|
||||||
|
export type PartTemplate<TMeta = {}> = Omit<Partial<Part<TMeta>>, 'id'> & TMeta;
|
||||||
|
|
||||||
|
export type PartPool<TMeta = {}> = {
|
||||||
|
parts: Part<TMeta>[];
|
||||||
|
template: PartTemplate<TMeta>;
|
||||||
|
draw(): Part<TMeta> | undefined;
|
||||||
|
return(part: Part<TMeta>): void;
|
||||||
|
remaining(): number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPart<TMeta = {}>(
|
||||||
|
template: PartTemplate<TMeta>,
|
||||||
|
id: string
|
||||||
|
): Part<TMeta> {
|
||||||
|
return {
|
||||||
|
regionId: '',
|
||||||
|
position: [],
|
||||||
|
...template,
|
||||||
|
id,
|
||||||
|
} as Part<TMeta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createParts<TMeta = {}>(
|
||||||
|
template: PartTemplate<TMeta>,
|
||||||
|
count: number,
|
||||||
|
idPrefix: string
|
||||||
|
): Part<TMeta>[] {
|
||||||
|
const parts: Part<TMeta>[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
parts.push(createPart(template, `${idPrefix}-${i + 1}`));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPartPool<TMeta = {}>(
|
||||||
|
template: PartTemplate<TMeta>,
|
||||||
|
count: number,
|
||||||
|
idPrefix: string
|
||||||
|
): PartPool<TMeta> {
|
||||||
|
const parts = createParts(template, count, idPrefix);
|
||||||
|
const available = [...parts];
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
template,
|
||||||
|
draw() {
|
||||||
|
return available.pop();
|
||||||
|
},
|
||||||
|
return(part: Part<TMeta>) {
|
||||||
|
part.regionId = '';
|
||||||
|
part.position = [];
|
||||||
|
available.push(part);
|
||||||
|
},
|
||||||
|
remaining() {
|
||||||
|
return available.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePartPools<TMeta = {}>(
|
||||||
|
...pools: PartPool<TMeta>[]
|
||||||
|
): PartPool<TMeta> {
|
||||||
|
if (pools.length === 0) {
|
||||||
|
return createPartPool({} as PartTemplate<TMeta>, 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<TMeta>) {
|
||||||
|
part.regionId = '';
|
||||||
|
part.position = [];
|
||||||
|
available.push(part);
|
||||||
|
},
|
||||||
|
remaining() {
|
||||||
|
return available.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {RNG} from "@/utils/rng";
|
import {RNG} from "@/utils/rng";
|
||||||
|
|
||||||
export type Part = {
|
export type Part<TMeta = {}> = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
sides?: number;
|
sides?: number;
|
||||||
|
|
@ -10,19 +10,33 @@ export type Part = {
|
||||||
alignment?: string;
|
alignment?: string;
|
||||||
regionId: string;
|
regionId: string;
|
||||||
position: number[];
|
position: number[];
|
||||||
}
|
} & TMeta;
|
||||||
|
|
||||||
export function flip(part: Part) {
|
export function flip<TMeta>(part: Part<TMeta>) {
|
||||||
if(!part.sides) return;
|
if(!part.sides) return;
|
||||||
part.side = ((part.side || 0) + 1) % part.sides;
|
part.side = ((part.side || 0) + 1) % part.sides;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flipTo(part: Part, side: number) {
|
export function flipTo<TMeta>(part: Part<TMeta>, side: number) {
|
||||||
if(!part.sides || side >= part.sides) return;
|
if(!part.sides || side >= part.sides) return;
|
||||||
part.side = side;
|
part.side = side;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roll(part: Part, rng: RNG) {
|
export function roll<TMeta>(part: Part<TMeta>, rng: RNG) {
|
||||||
if(!part.sides) return;
|
if(!part.sides) return;
|
||||||
part.side = rng.nextInt(part.sides);
|
part.side = rng.nextInt(part.sides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findPartById<TMeta>(parts: Part<TMeta>[], id: string): Part<TMeta> | undefined {
|
||||||
|
return parts.find(p => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellOccupied<TMeta>(parts: Part<TMeta>[], regionId: string, position: number[]): boolean {
|
||||||
|
const posKey = position.join(',');
|
||||||
|
return parts.some(p => p.regionId === regionId && p.position.join(',') === posKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPartAtPosition<TMeta>(parts: Part<TMeta>[], regionId: string, position: number[]): Part<TMeta> | undefined {
|
||||||
|
const posKey = position.join(',');
|
||||||
|
return parts.find(p => p.regionId === regionId && p.position.join(',') === posKey);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export function createRegion(id: string, axes: RegionAxis[]): Region {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPartMap(region: Region, parts: Record<string, Part>) {
|
function buildPartMap<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const childId of region.childIds) {
|
for (const childId of region.childIds) {
|
||||||
const part = parts[childId];
|
const part = parts[childId];
|
||||||
|
|
@ -35,7 +35,7 @@ function buildPartMap(region: Region, parts: Record<string, Part>) {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAlign(region: Region, parts: Record<string, Part>) {
|
export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
|
||||||
if (region.childIds.length === 0) return;
|
if (region.childIds.length === 0) return;
|
||||||
|
|
||||||
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
||||||
|
|
@ -98,7 +98,7 @@ export function applyAlign(region: Region, parts: Record<string, Part>) {
|
||||||
region.partMap = buildPartMap(region, parts);
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
|
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
|
||||||
if (region.childIds.length <= 1) return;
|
if (region.childIds.length <= 1) return;
|
||||||
|
|
||||||
const childIds = [...region.childIds];
|
const childIds = [...region.childIds];
|
||||||
|
|
@ -117,9 +117,11 @@ export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
|
||||||
region.partMap = buildPartMap(region, parts);
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Region, position?: number[]) {
|
export function moveToRegion<TMeta>(part: Part<TMeta>, sourceRegion: Region | null, targetRegion: Region, position?: number[]) {
|
||||||
|
if (sourceRegion && part.regionId === sourceRegion.id) {
|
||||||
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
||||||
delete sourceRegion.partMap[part.position.join(',')];
|
delete sourceRegion.partMap[part.position.join(',')];
|
||||||
|
}
|
||||||
|
|
||||||
targetRegion.childIds.push(part.id);
|
targetRegion.childIds.push(part.id);
|
||||||
if (position) {
|
if (position) {
|
||||||
|
|
@ -130,11 +132,13 @@ export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Reg
|
||||||
part.regionId = targetRegion.id;
|
part.regionId = targetRegion.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegion: Region, positions?: number[][]) {
|
export function moveToRegionAll<TMeta>(parts: Part<TMeta>[], sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) {
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
|
if (sourceRegion && part.regionId === sourceRegion.id) {
|
||||||
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
|
||||||
delete sourceRegion.partMap[part.position.join(',')];
|
delete sourceRegion.partMap[part.position.join(',')];
|
||||||
|
}
|
||||||
|
|
||||||
targetRegion.childIds.push(part.id);
|
targetRegion.childIds.push(part.id);
|
||||||
if (positions && positions[i]) {
|
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<TMeta>(part: Part<TMeta>, region: Region) {
|
||||||
region.childIds = region.childIds.filter(id => id !== part.id);
|
region.childIds = region.childIds.filter(id => id !== part.id);
|
||||||
delete region.partMap[part.position.join(',')];
|
delete region.partMap[part.position.join(',')];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ export type { IGameContext } from './core/game';
|
||||||
export { createGameContext, createGameCommandRegistry } 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, 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 type { Region, RegionAxis } from './core/region';
|
||||||
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
|
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
|
||||||
|
|
|
||||||
|
|
@ -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 BOARD_SIZE = 6;
|
||||||
const MAX_PIECES_PER_PLAYER = 8;
|
const MAX_PIECES_PER_PLAYER = 8;
|
||||||
|
|
@ -8,7 +8,7 @@ export type PlayerType = 'white' | 'black';
|
||||||
export type PieceType = 'kitten' | 'cat';
|
export type PieceType = 'kitten' | 'cat';
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
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 };
|
type PieceSupply = { supply: number; placed: number };
|
||||||
|
|
||||||
|
|
@ -157,15 +157,11 @@ export function getBoardRegion(host: MutableSignal<BoopState>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
|
||||||
const board = getBoardRegion(host);
|
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
|
||||||
return board.partMap[`${row},${col}`] !== undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
|
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
|
||||||
const board = getBoardRegion(host);
|
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
|
||||||
const partId = board.partMap[`${row},${col}`];
|
|
||||||
if (!partId) return null;
|
|
||||||
return host.value.pieces.find(p => p.id === partId) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
|
||||||
|
|
@ -173,13 +169,10 @@ export function placePiece(host: MutableSignal<BoopState>, row: number, col: num
|
||||||
const playerData = getPlayer(host, player);
|
const playerData = getPlayer(host, player);
|
||||||
const count = playerData[pieceType].placed + 1;
|
const count = playerData[pieceType].placed + 1;
|
||||||
|
|
||||||
const piece: BoopPart = {
|
const piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
|
||||||
id: `${player}-${pieceType}-${count}`,
|
{ regionId: 'board', position: [row, col], player, pieceType },
|
||||||
regionId: 'board',
|
`${player}-${pieceType}-${count}`
|
||||||
position: [row, col],
|
);
|
||||||
player,
|
|
||||||
pieceType,
|
|
||||||
};
|
|
||||||
host.produce(s => {
|
host.produce(s => {
|
||||||
s.pieces.push(piece);
|
s.pieces.push(piece);
|
||||||
board.childIds.push(piece.id);
|
board.childIds.push(piece.id);
|
||||||
|
|
|
||||||
|
|
@ -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 BOARD_SIZE = 3;
|
||||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
|
@ -16,7 +16,7 @@ const WINNING_LINES: number[][][] = [
|
||||||
export type PlayerType = 'X' | 'O';
|
export type PlayerType = 'X' | 'O';
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
|
|
||||||
type TicTacToePart = Part & { player: PlayerType };
|
type TicTacToePart = Part<{ player: PlayerType }>;
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -24,7 +24,7 @@ export function createInitialState() {
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
]),
|
]),
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
parts: [] as TicTacToePart[],
|
||||||
currentPlayer: 'X' as PlayerType,
|
currentPlayer: 'X' as PlayerType,
|
||||||
winner: null as WinnerType,
|
winner: null as WinnerType,
|
||||||
turn: 0,
|
turn: 0,
|
||||||
|
|
@ -91,8 +91,7 @@ function isValidMove(row: number, col: number): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
||||||
const board = host.value.board;
|
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
|
||||||
return board.partMap[`${row},${col}`] !== undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
|
|
@ -104,7 +103,7 @@ export function hasWinningLine(positions: number[][]): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
export function checkWinner(host: MutableSignal<TicTacToeState>): 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 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 oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||||
|
|
@ -118,15 +117,13 @@ export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
||||||
|
|
||||||
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||||
const board = host.value.board;
|
const board = host.value.board;
|
||||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
const moveNumber = host.value.parts.length + 1;
|
||||||
const piece: TicTacToePart = {
|
const piece = createPart<{ player: PlayerType }>(
|
||||||
id: `piece-${player}-${moveNumber}`,
|
{ regionId: 'board', position: [row, col], player },
|
||||||
regionId: 'board',
|
`piece-${player}-${moveNumber}`
|
||||||
position: [row, col],
|
);
|
||||||
player,
|
|
||||||
};
|
|
||||||
host.produce(state => {
|
host.produce(state => {
|
||||||
state.parts[piece.id] = piece;
|
state.parts.push(piece);
|
||||||
board.childIds.push(piece.id);
|
board.childIds.push(piece.id);
|
||||||
board.partMap[`${row},${col}`] = piece.id;
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -163,9 +163,9 @@ describe('TicTacToe - helper functions', () => {
|
||||||
const state = getState(ctx);
|
const state = getState(ctx);
|
||||||
placePiece(state, 1, 1, 'X');
|
placePiece(state, 1, 1, 'X');
|
||||||
|
|
||||||
expect(Object.keys(state.value.parts).length).toBe(1);
|
expect(state.value.parts.length).toBe(1);
|
||||||
expect(state.value.parts['piece-X-1'].position).toEqual([1, 1]);
|
expect(state.value.parts.find(p => p.id === 'piece-X-1')!.position).toEqual([1, 1]);
|
||||||
expect(state.value.parts['piece-X-1'].player).toBe('X');
|
expect(state.value.parts.find(p => p.id === 'piece-X-1')!.player).toBe('X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add piece to board region children', () => {
|
it('should add piece to board region children', () => {
|
||||||
|
|
@ -183,7 +183,7 @@ describe('TicTacToe - helper functions', () => {
|
||||||
placePiece(state, 0, 0, 'X');
|
placePiece(state, 0, 0, 'X');
|
||||||
placePiece(state, 0, 1, 'O');
|
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);
|
expect(new Set(ids).size).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -229,8 +229,8 @@ describe('TicTacToe - game flow', () => {
|
||||||
const result = await runPromise;
|
const result = await runPromise;
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) expect(result.result.winner).toBeNull();
|
if (result.success) expect(result.result.winner).toBeNull();
|
||||||
expect(Object.keys(ctx.state.value.parts).length).toBe(1);
|
expect(ctx.state.value.parts.length).toBe(1);
|
||||||
expect(ctx.state.value.parts['piece-X-1'].position).toEqual([1, 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 () => {
|
it('should reject move for wrong player and re-prompt', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue