feat(chess): add Save/Load panel + time-travel undo (P3.12)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 16:12:15 -06:00
commit bc753aadfd
No known key found for this signature in database
4 changed files with 206 additions and 19 deletions

View file

@ -1,32 +1,51 @@
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, useNavigate } from 'react-router-dom'
import { GameView } from '../ui/GameView'
import { RulesView } from '../ui/RulesView'
import { SavePanel } from '../ui/SavePanel'
import { useChessEngine } from '../hooks/useChessEngine'
import { ChessEngine } from '../engine'
import type { AttrKey, FactValue } from '@paratype/rete'
export function App() {
const chessState = useChessEngine()
return (
<div data-testid="app-root">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<GameView />} />
<Route path="/game" element={<GameView engineState={chessState} />} />
<Route path="/rules" element={<RulesView />} />
<Route path="/save" element={<Save />} />
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
</Routes>
</div>
)
}
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
const navigate = useNavigate()
return (
<SavePanel
currentFacts={chessState.facts}
onLoad={(loadedFacts) => {
const newEngine = new ChessEngine()
const oldFacts = newEngine.session.allFacts();
for (const f of oldFacts) {
newEngine.session.retract(f.id, f.attr as AttrKey);
}
for (const fact of loadedFacts) {
newEngine.session.insert(fact.id, fact.attr as AttrKey, fact.value as FactValue)
}
chessState.loadEngine(newEngine)
navigate('/game')
}}
/>
)
}
function Home() {
return (
<main data-testid="page-home">
<h1>Home</h1>
</main>
)
}
function Save() {
return (
<main data-testid="page-save">
<h1>Save</h1>
</main>
)
}
}

View file

@ -4,6 +4,7 @@ import type { PieceType } from '../schema';
export function useChessEngine() {
const engineRef = useRef<ChessEngine | null>(null);
const moveHistoryRef = useRef<Array<{ from: number, to: number, promoteTo: PieceType }>>([]);
// Force re-render state
const [tick, setTick] = useState(0);
@ -15,6 +16,12 @@ export function useChessEngine() {
const engine = engineRef.current;
const loadEngine = useCallback((newEngine: ChessEngine) => {
engineRef.current = newEngine;
moveHistoryRef.current = [];
setTick(t => t + 1);
}, []);
const getTurn = useCallback(() => {
return engine.getCurrentTurn();
}, [engine, tick]);
@ -34,6 +41,7 @@ export function useChessEngine() {
const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
const move = engine.findMove(from, to, promoteTo);
if (move) {
moveHistoryRef.current.push({ from, to, promoteTo });
const result = engine.applyMove(move, promoteTo);
setTick(t => t + 1); // trigger re-render
return result;
@ -41,6 +49,21 @@ export function useChessEngine() {
return null;
}, [engine]);
const undo = useCallback(() => {
if (moveHistoryRef.current.length > 0) {
moveHistoryRef.current.pop();
const newEngine = new ChessEngine();
for (const historyMove of moveHistoryRef.current) {
const move = newEngine.findMove(historyMove.from, historyMove.to, historyMove.promoteTo);
if (move) {
newEngine.applyMove(move, historyMove.promoteTo);
}
}
engineRef.current = newEngine;
setTick(t => t + 1);
}
}, []);
return {
engine,
turn: getTurn(),
@ -48,5 +71,8 @@ export function useChessEngine() {
legalMoves: getLegalMoves(),
result: getResult(),
applyMove,
undo,
canUndo: moveHistoryRef.current.length > 0,
loadEngine,
};
}

View file

@ -1,14 +1,17 @@
import { useChessEngine } from '../hooks/useChessEngine';
import { Board } from './Board';
import { useChessEngine } from '../hooks/useChessEngine';
import type { ChessFact, ChessAttrMap } from '../schema';
interface GameViewProps {
onUndo?: () => void;
engineState?: ReturnType<typeof useChessEngine>;
}
export function GameView({ onUndo }: GameViewProps) {
const { facts, legalMoves, turn, result, applyMove } = useChessEngine();
export function GameView({ engineState }: GameViewProps) {
// Use passed in engine state or create local state if none provided
const localChessState = useChessEngine();
const state = engineState || localChessState;
const { facts, legalMoves, turn, result, applyMove, undo, canUndo } = state;
const handleMove = (from: number, to: number) => {
applyMove(from, to, 'queen');
@ -39,8 +42,9 @@ export function GameView({ onUndo }: GameViewProps) {
<button
data-action="undo"
onClick={onUndo}
className="px-4 py-2 bg-white hover:bg-neutral-50 active:bg-neutral-100 text-neutral-600 font-medium rounded-md border border-neutral-300 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
onClick={undo}
disabled={!canUndo}
className={`px-4 py-2 bg-white font-medium rounded-md border border-neutral-300 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200 ${canUndo ? 'hover:bg-neutral-50 active:bg-neutral-100 text-neutral-600' : 'opacity-50 cursor-not-allowed text-neutral-400'}`}
title="Undo last move"
>
Undo

View file

@ -0,0 +1,138 @@
import { useState, useEffect } from 'react';
import type { EntityId } from '@paratype/rete';
export interface SaveSlot {
name: string;
facts: Array<{ id: EntityId; attr: string; value: unknown }>;
timestamp: number;
}
interface SavePanelProps {
onLoad: (facts: Array<{ id: EntityId; attr: string; value: unknown }>) => void;
currentFacts?: Array<{ id: EntityId; attr: string; value: unknown }>;
}
const LOCAL_STORAGE_PREFIX = 'paratype-chess:v1:slot:';
export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
const [slots, setSlots] = useState<SaveSlot[]>([]);
const [newSlotName, setNewSlotName] = useState('');
const loadSlots = () => {
const loadedSlots: SaveSlot[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(LOCAL_STORAGE_PREFIX)) {
try {
const item = localStorage.getItem(key);
if (item) {
const slot = JSON.parse(item) as SaveSlot;
loadedSlots.push(slot);
}
} catch (e) {
console.error(`Failed to parse save slot for key: ${key}`, e);
}
}
}
loadedSlots.sort((a, b) => b.timestamp - a.timestamp);
setSlots(loadedSlots);
};
useEffect(() => {
loadSlots();
}, []);
const handleSave = () => {
if (!newSlotName.trim() || !currentFacts) return;
const slot: SaveSlot = {
name: newSlotName.trim(),
facts: currentFacts,
timestamp: Date.now(),
};
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot));
setNewSlotName('');
loadSlots();
};
const handleDelete = (name: string) => {
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`);
loadSlots();
};
return (
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-2xl mx-auto px-4">
<div className="w-full">
<h1 className="text-3xl font-bold tracking-tight text-neutral-800 mb-8">Save / Load Game</h1>
<div className="bg-white rounded-lg border border-neutral-200 shadow-sm overflow-hidden mb-8">
<div className="p-6 border-b border-neutral-100 bg-neutral-50/50">
<h2 className="text-lg font-semibold text-neutral-800 mb-4">Create New Save</h2>
<div className="flex gap-3">
<input
type="text"
value={newSlotName}
onChange={(e) => setNewSlotName(e.target.value)}
placeholder="Name your save slot..."
className="flex-1 px-4 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-neutral-200"
disabled={!currentFacts}
/>
<button
data-action="save"
onClick={handleSave}
disabled={!newSlotName.trim() || !currentFacts}
className="px-6 py-2 bg-neutral-900 text-white font-medium rounded-md hover:bg-neutral-800 active:bg-neutral-950 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
</div>
{!currentFacts && (
<p className="text-sm text-neutral-500 mt-2">Start a game to enable saving.</p>
)}
</div>
<div className="p-0">
{slots.length === 0 ? (
<div className="p-8 text-center text-neutral-500">
No saved games found.
</div>
) : (
<ul className="divide-y divide-neutral-100">
{slots.map((slot) => (
<li
key={slot.name}
data-testid={`save-slot-${slot.name}`}
className="p-4 flex items-center justify-between hover:bg-neutral-50/50 transition-colors"
>
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{slot.name}</span>
<span className="text-sm text-neutral-500">
{new Date(slot.timestamp).toLocaleString()}
</span>
</div>
<div className="flex gap-2">
<button
data-action="load"
onClick={() => onLoad(slot.facts)}
className="px-4 py-1.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 active:bg-neutral-100 transition-colors"
>
Load
</button>
<button
onClick={() => handleDelete(slot.name)}
className="px-4 py-1.5 text-sm font-medium text-red-600 bg-white border border-neutral-300 rounded-md hover:bg-red-50 hover:border-red-200 active:bg-red-100 transition-colors"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}