feat(shared): pure phantom-model helpers (seed positions, deserialize)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-05-18 20:20:46 -04:00
parent 783d85a40c
commit a574100e25
3 changed files with 80 additions and 0 deletions
+1
View File
@@ -2,3 +2,4 @@ export * from './types.js';
export * from './moderator.js';
export * from './protocol.js';
export * from './geometric.js';
export * from './phantoms.js';
+41
View File
@@ -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<Record<Square, Piece>> {
const backRank = opponentColor === 'w' ? '1' : '8';
const pawnRank = opponentColor === 'w' ? '2' : '7';
const out: Partial<Record<Square, Piece>> = {};
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<Record<Square, Piece>> {
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<Record<Square, Piece>> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
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;
}
+38
View File
@@ -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' } });
});
});