diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 55b6bed..85fbe3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,3 +2,4 @@ export * from './types.js'; export * from './moderator.js'; export * from './protocol.js'; export * from './geometric.js'; +export * from './phantoms.js'; diff --git a/packages/shared/src/phantoms.ts b/packages/shared/src/phantoms.ts new file mode 100644 index 0000000..26c1bc0 --- /dev/null +++ b/packages/shared/src/phantoms.ts @@ -0,0 +1,41 @@ +import { FILES, isSquare, type Color, type Piece, type PieceType, type Square } from './types.js'; + +const BACK_RANK: PieceType[] = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r']; +const COLORS: Color[] = ['w', 'b']; +const TYPES: PieceType[] = ['p', 'n', 'b', 'r', 'q', 'k']; + +/** + * The standard starting position for one colour, as a square→piece map. + * Used to seed the phantom opponent-model layer with the opponent's army. + */ +export function opponentStartPosition(opponentColor: Color): Partial> { + const backRank = opponentColor === 'w' ? '1' : '8'; + const pawnRank = opponentColor === 'w' ? '2' : '7'; + const out: Partial> = {}; + FILES.forEach((file, i) => { + out[`${file}${backRank}` as Square] = { color: opponentColor, type: BACK_RANK[i]! }; + out[`${file}${pawnRank}` as Square] = { color: opponentColor, type: 'p' }; + }); + return out; +} + +/** + * Parse a persisted phantom map (from localStorage). Tolerant: returns {} on + * any structural failure and silently drops individual invalid entries. + */ +export function deserializePhantoms(raw: string | null): Partial> { + if (!raw) return {}; + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return {}; } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const out: Partial> = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (!isSquare(k)) continue; + if (typeof v !== 'object' || v === null) continue; + const { color, type } = v as { color?: unknown; type?: unknown }; + if (!COLORS.includes(color as Color)) continue; + if (!TYPES.includes(type as PieceType)) continue; + out[k] = { color: color as Color, type: type as PieceType }; + } + return out; +} diff --git a/packages/shared/test/phantoms.test.ts b/packages/shared/test/phantoms.test.ts new file mode 100644 index 0000000..881706c --- /dev/null +++ b/packages/shared/test/phantoms.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { opponentStartPosition, deserializePhantoms } from '../src/phantoms.js'; + +describe('opponentStartPosition', () => { + it('seeds 16 black pieces on ranks 7-8', () => { + const p = opponentStartPosition('b'); + expect(Object.keys(p).length).toBe(16); + expect(p.e8).toEqual({ color: 'b', type: 'k' }); + expect(p.d8).toEqual({ color: 'b', type: 'q' }); + expect(p.a8).toEqual({ color: 'b', type: 'r' }); + expect(p.h7).toEqual({ color: 'b', type: 'p' }); + }); + + it('seeds 16 white pieces on ranks 1-2', () => { + const p = opponentStartPosition('w'); + expect(Object.keys(p).length).toBe(16); + expect(p.e1).toEqual({ color: 'w', type: 'k' }); + expect(p.a2).toEqual({ color: 'w', type: 'p' }); + }); +}); + +describe('deserializePhantoms', () => { + it('returns {} for null or invalid JSON', () => { + expect(deserializePhantoms(null)).toEqual({}); + expect(deserializePhantoms('not json')).toEqual({}); + expect(deserializePhantoms('[]')).toEqual({}); + }); + + it('keeps valid entries and drops invalid ones', () => { + const raw = JSON.stringify({ + e5: { color: 'b', type: 'n' }, + zz: { color: 'b', type: 'p' }, // invalid square + a1: { color: 'x', type: 'p' }, // invalid colour + b2: { color: 'b', type: 'z' }, // invalid type + }); + expect(deserializePhantoms(raw)).toEqual({ e5: { color: 'b', type: 'n' } }); + }); +});