feat(chess): add JSON export/import with validation (P3.13)
This commit is contained in:
parent
bc753aadfd
commit
3fdeb5822d
3 changed files with 191 additions and 16 deletions
|
|
@ -2,9 +2,10 @@ import { Routes, Route, useNavigate } from 'react-router-dom'
|
||||||
import { GameView } from '../ui/GameView'
|
import { GameView } from '../ui/GameView'
|
||||||
import { RulesView } from '../ui/RulesView'
|
import { RulesView } from '../ui/RulesView'
|
||||||
import { SavePanel } from '../ui/SavePanel'
|
import { SavePanel } from '../ui/SavePanel'
|
||||||
|
import { ImportExport } from '../ui/ImportExport'
|
||||||
import { useChessEngine } from '../hooks/useChessEngine'
|
import { useChessEngine } from '../hooks/useChessEngine'
|
||||||
import { ChessEngine } from '../engine'
|
import { ChessEngine } from '../engine'
|
||||||
import type { AttrKey, FactValue } from '@paratype/rete'
|
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const chessState = useChessEngine()
|
const chessState = useChessEngine()
|
||||||
|
|
@ -23,22 +24,33 @@ export function App() {
|
||||||
|
|
||||||
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
|
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLoad = (loadedFacts: Array<{ id: number; attr: string; value: unknown }>) => {
|
||||||
|
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 as EntityId, fact.attr as AttrKey, fact.value as FactValue)
|
||||||
|
}
|
||||||
|
chessState.loadEngine(newEngine)
|
||||||
|
navigate('/game')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SavePanel
|
<div className="flex flex-col gap-8">
|
||||||
currentFacts={chessState.facts}
|
<SavePanel
|
||||||
onLoad={(loadedFacts) => {
|
currentFacts={chessState.facts}
|
||||||
const newEngine = new ChessEngine()
|
onLoad={handleLoad}
|
||||||
const oldFacts = newEngine.session.allFacts();
|
/>
|
||||||
for (const f of oldFacts) {
|
<div className="w-full max-w-2xl mx-auto px-4">
|
||||||
newEngine.session.retract(f.id, f.attr as AttrKey);
|
<ImportExport
|
||||||
}
|
currentFacts={chessState.facts}
|
||||||
for (const fact of loadedFacts) {
|
onLoad={handleLoad}
|
||||||
newEngine.session.insert(fact.id, fact.attr as AttrKey, fact.value as FactValue)
|
/>
|
||||||
}
|
</div>
|
||||||
chessState.loadEngine(newEngine)
|
</div>
|
||||||
navigate('/game')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
63
packages/chess/src/persist/io.ts
Normal file
63
packages/chess/src/persist/io.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
export interface GameExport {
|
||||||
|
version: 1;
|
||||||
|
exportedAt: number;
|
||||||
|
facts: Array<{ id: number; attr: string; value: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImportError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ImportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportGame(facts: Array<{ id: number; attr: string; value: unknown }>): string {
|
||||||
|
const payload: GameExport = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
facts
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importGame(json: string): Array<{ id: number; attr: string; value: unknown }> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
throw new ImportError('Invalid JSON format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new ImportError('Invalid export format: not an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (exportData.version !== 1) {
|
||||||
|
throw new ImportError(`Unsupported export version: ${exportData.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(exportData.facts)) {
|
||||||
|
throw new ImportError('Invalid export format: missing facts array');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < exportData.facts.length; i++) {
|
||||||
|
const fact = exportData.facts[i];
|
||||||
|
if (!fact || typeof fact !== 'object') {
|
||||||
|
throw new ImportError(`Invalid fact at index ${i}: not an object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, attr } = fact as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof id !== 'number') {
|
||||||
|
throw new ImportError(`Invalid fact at index ${i}: id must be a number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof attr !== 'string') {
|
||||||
|
throw new ImportError(`Invalid fact at index ${i}: attr must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportData.facts as Array<{ id: number; attr: string; value: unknown }>;
|
||||||
|
}
|
||||||
100
packages/chess/src/ui/ImportExport.tsx
Normal file
100
packages/chess/src/ui/ImportExport.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { exportGame, importGame } from '../persist/io';
|
||||||
|
|
||||||
|
interface ImportExportProps {
|
||||||
|
currentFacts?: Array<{ id: number; attr: string; value: unknown }>;
|
||||||
|
onLoad: (facts: Array<{ id: number; attr: string; value: unknown }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportExport({ currentFacts, onLoad }: ImportExportProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!currentFacts) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = exportGame(currentFacts);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chess-game-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
setError(null);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
const facts = importGame(text);
|
||||||
|
onLoad(facts);
|
||||||
|
// Reset file input so the same file can be selected again
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown import error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
setError('Failed to read file');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight text-neutral-800 mb-6">Export / Import</h2>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-neutral-200 shadow-sm overflow-hidden mb-8 p-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="import-error"
|
||||||
|
className="mb-6 p-4 bg-red-50 text-red-700 rounded-md border border-red-200"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<button
|
||||||
|
data-action="export"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={!currentFacts}
|
||||||
|
className="flex-1 px-6 py-3 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"
|
||||||
|
>
|
||||||
|
Export to File
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-action="import"
|
||||||
|
onClick={handleImportClick}
|
||||||
|
className="flex-1 px-6 py-3 text-neutral-700 bg-white border border-neutral-300 font-medium rounded-md hover:bg-neutral-50 active:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
Import from File
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue