diff --git a/guide/age-of-four/src/hooks/usePlayerPreferences.ts b/guide/age-of-four/src/hooks/usePlayerPreferences.ts deleted file mode 100644 index e91f50fe..00000000 --- a/guide/age-of-four/src/hooks/usePlayerPreferences.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useState, useCallback } from 'react' - -export type RacePreference = 'high_elves' | 'humans' | 'dwarves' | 'orcs' | 'random' -export type GenderPreference = 'male' | 'female' | 'random' -export type ColorMode = 'dark' | 'light' -export type FontSize = 'sm' | 'md' | 'lg' | 'xl' - -export const FONT_SIZE_PX: Record = { - sm: 13, - md: 15, - lg: 17, - xl: 19, -} - -export interface PlayerPreferences { - race: RacePreference - gender: GenderPreference - name: string - colorMode: ColorMode - dyslexicFont: boolean - fontSize: FontSize -} - -const STORAGE_KEY = 'mgc_guide_preferences' - -const DEFAULTS: Omit = { - name: '', - colorMode: 'dark', - dyslexicFont: false, - fontSize: 'md', -} - -function load(): PlayerPreferences | null { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as Partial - if (!parsed.race || !parsed.gender) return null - return { ...DEFAULTS, ...parsed } as PlayerPreferences - } catch { - return null - } -} - -function save(prefs: PlayerPreferences): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) -} - -export function usePlayerPreferences(): { - preferences: PlayerPreferences | null - save: (prefs: PlayerPreferences) => void - isFirstVisit: boolean -} { - const [preferences, setPreferences] = useState(load) - - const savePreferences = useCallback((prefs: PlayerPreferences) => { - save(prefs) - setPreferences(prefs) - }, []) - - return { - preferences, - save: savePreferences, - isFirstVisit: preferences === null, - } -} diff --git a/guide/age-of-four/src/hooks/useSimulationWorker.ts b/guide/age-of-four/src/hooks/useSimulationWorker.ts deleted file mode 100644 index a7394901..00000000 --- a/guide/age-of-four/src/hooks/useSimulationWorker.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import type { TurnStats, EcologicalEvent, TerrainData } from '@magic-civ/engine-ts' -import type { WorkerResponse, FramePayload } from '@magic-civ/engine-ts' -import { buildTerrainCache, loadClimateParams } from '@magic-civ/engine-ts' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ScenarioData { - stats: TurnStats[] - events: EcologicalEvent[][] - totalTurns: number - bufferReady: boolean -} - -export interface SimProgress { - scenarioId: string - pct: number - phase: 'geology' | 'scenario' | 'buffering' - /** For buffering phase: frames encoded so far. */ - bufferedFrames?: number - /** For buffering phase: total frames to pre-encode. */ - totalBufferFrames?: number -} - -export interface UseSimulationWorkerResult { - scenarios: Map - currentFrame: FramePayload | null - referenceFrame: FramePayload | null - progress: SimProgress | null - isReady: boolean - - runScenario: (scenarioId: string, turns: number, bufferFrames: number, seed?: number) => void - extendScenario: (scenarioId: string, turns: number) => void - requestFrame: (scenarioId: string, turn: number) => void - cancelScenario: (scenarioId: string) => void -} - -// --------------------------------------------------------------------------- -// LRU frame cache -// --------------------------------------------------------------------------- - -/** LRU capacity: 750 prebuffer + LOOKAHEAD headroom per active scenario. */ -const MAX_CACHED_FRAMES = 800 -const LOOKAHEAD = 15 - -class FrameCache { - private entries = new Map() - private order: string[] = [] - - private key(scenarioId: string, turn: number): string { - return `${scenarioId}:${turn}` - } - - get(scenarioId: string, turn: number): FramePayload | undefined { - const k = this.key(scenarioId, turn) - const frame = this.entries.get(k) - if (frame) { - // Move to end (most recently used) - const idx = this.order.indexOf(k) - if (idx >= 0) this.order.splice(idx, 1) - this.order.push(k) - } - return frame - } - - set(scenarioId: string, turn: number, frame: FramePayload): void { - const k = this.key(scenarioId, turn) - if (!this.entries.has(k)) { - // Evict oldest if over capacity - while (this.order.length >= MAX_CACHED_FRAMES) { - const oldest = this.order.shift() - if (oldest) this.entries.delete(oldest) - } - this.order.push(k) - } - this.entries.set(k, frame) - } - - has(scenarioId: string, turn: number): boolean { - return this.entries.has(this.key(scenarioId, turn)) - } - - clearScenario(scenarioId: string): void { - const prefix = `${scenarioId}:` - for (const k of [...this.order]) { - if (k.startsWith(prefix)) { - this.entries.delete(k) - const idx = this.order.indexOf(k) - if (idx >= 0) this.order.splice(idx, 1) - } - } - } -} - -// --------------------------------------------------------------------------- -// Hook -// --------------------------------------------------------------------------- - -export function useSimulationWorker(): UseSimulationWorkerResult { - const [scenarios, setScenarios] = useState>(new Map()) - const [currentFrame, setCurrentFrame] = useState(null) - const [referenceFrame, setReferenceFrame] = useState(null) - const [progress, setProgress] = useState(null) - const [isReady, setIsReady] = useState(false) - - const workerRef = useRef(null) - const frameCacheRef = useRef(new FrameCache()) - const pendingFrameRef = useRef(null) // "scenarioId:turn" of outstanding request - const refFrameRequestedRef = useRef>(new Set()) - - // ── Initialize worker ────────────────────────────────────────────────── - useEffect(() => { - const worker = new Worker( - new URL('../simulation/simulation.worker.ts', import.meta.url), - { type: 'module' }, - ) - workerRef.current = worker - - worker.onmessage = (e: MessageEvent): void => { - const msg = e.data - switch (msg.type) { - case 'ready': - setIsReady(true) - break - - case 'progress': - if (msg.phase === 'buffering') { - setProgress({ - scenarioId: msg.scenarioId, - pct: msg.total > 0 ? (msg.turn / msg.total) * 100 : 0, - phase: 'buffering', - bufferedFrames: msg.turn, - totalBufferFrames: msg.total, - }) - } else { - setProgress({ - scenarioId: msg.scenarioId, - pct: msg.total > 0 ? (msg.turn / msg.total) * 100 : 0, - phase: msg.phase, - }) - } - break - - case 'stats': - setScenarios((prev) => { - const next = new Map(prev) - const existing = next.get(msg.scenarioId) - next.set(msg.scenarioId, { - stats: msg.allStats, - events: msg.allEvents, - totalTurns: existing?.totalTurns ?? msg.allStats.length, - bufferReady: existing?.bufferReady ?? false, - }) - return next - }) - break - - case 'done': - setScenarios((prev) => { - const next = new Map(prev) - const existing = next.get(msg.scenarioId) - if (existing) { - next.set(msg.scenarioId, { ...existing, totalTurns: msg.totalTurns }) - } - return next - }) - // Don't clear progress — buffering phase follows immediately - break - - case 'prebuffer_done': - setScenarios((prev) => { - const next = new Map(prev) - const existing = next.get(msg.scenarioId) - if (existing) { - next.set(msg.scenarioId, { ...existing, bufferReady: true }) - } - return next - }) - setProgress(null) - break - - case 'frames': { - const cache = frameCacheRef.current - for (let i = 0; i < msg.snapshots.length; i++) { - cache.set(msg.scenarioId, msg.startTurn + i, msg.snapshots[i]) - } - // If the first frame matches the pending request, render it - if (msg.snapshots.length > 0) { - const first = msg.snapshots[0] - const pendingKey = `${msg.scenarioId}:${msg.startTurn}` - if (pendingFrameRef.current === pendingKey) { - setCurrentFrame(first) - pendingFrameRef.current = null - } - // Store reference frame (turn 0) if this is the first request - if (msg.startTurn === 0) { - setReferenceFrame(first) - refFrameRequestedRef.current.add(msg.scenarioId) - } - } - break - } - - case 'error': - console.error(`[SimWorker] ${msg.scenarioId}: ${msg.message}`) - setProgress(null) - break - } - } - - worker.onerror = (err: ErrorEvent): void => { - console.error('[SimWorker] Unhandled error:', err.message) - } - - // Load data on main thread (uses import.meta.glob) and send to worker - try { - const terrainCache = buildTerrainCache() - const params = loadClimateParams() - const terrainData: Record = Object.fromEntries(terrainCache) - worker.postMessage({ type: 'init', terrainData, params }) - } catch (err) { - console.error('[SimWorker] Failed to load data:', err) - } - - return () => { - worker.terminate() - workerRef.current = null - } - }, []) - - // ── Actions ──────────────────────────────────────────────────────────── - - const runScenario = useCallback((scenarioId: string, turns: number, bufferFrames: number, seed?: number) => { - const worker = workerRef.current - if (!worker) return - frameCacheRef.current.clearScenario(scenarioId) - refFrameRequestedRef.current.delete(scenarioId) - worker.postMessage({ type: 'run', scenarioId, turns, prebufferFrames: bufferFrames, seed }) - }, []) - - const extendScenario = useCallback((scenarioId: string, turns: number) => { - const worker = workerRef.current - if (!worker) return - worker.postMessage({ type: 'extend', scenarioId, turns }) - }, []) - - const requestFrame = useCallback((scenarioId: string, turn: number) => { - const worker = workerRef.current - if (!worker) return - - const cache = frameCacheRef.current - - // Check cache first - const cached = cache.get(scenarioId, turn) - if (cached) { - setCurrentFrame(cached) - return - } - - // Don't spam duplicate requests - const requestKey = `${scenarioId}:${turn}` - if (pendingFrameRef.current === requestKey) return - - pendingFrameRef.current = requestKey - worker.postMessage({ type: 'frame', scenarioId, turn, lookahead: LOOKAHEAD }) - - // Also request reference frame (turn 0) if not yet cached - if (!refFrameRequestedRef.current.has(scenarioId) && !cache.has(scenarioId, 0)) { - refFrameRequestedRef.current.add(scenarioId) - worker.postMessage({ type: 'frame', scenarioId, turn: 0, lookahead: 1 }) - } - }, []) - - const cancelScenario = useCallback((scenarioId: string) => { - const worker = workerRef.current - if (!worker) return - worker.postMessage({ type: 'cancel', scenarioId }) - }, []) - - return { - scenarios, - currentFrame, - referenceFrame, - progress, - isReady, - runScenario, - extendScenario, - requestFrame, - cancelScenario, - } -} diff --git a/guide/age-of-four/src/hooks/useSpriteAudit.ts b/guide/age-of-four/src/hooks/useSpriteAudit.ts deleted file mode 100644 index 7deda8dc..00000000 --- a/guide/age-of-four/src/hooks/useSpriteAudit.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' - -export interface SpriteAuditResult { - missing: ReadonlySet - checked: number - total: number - done: boolean -} - -/** - * Batch HEAD-checks sprite paths against the Vite public dir. - * Vite SPA mode returns 200 text/html for unknown paths, so we detect - * missing files by checking Content-Type — real images return image/*. - * Progress is streamed as individual checks resolve. - */ -export function useSpriteAudit(paths: readonly string[]): SpriteAuditResult { - const [missing, setMissing] = useState>(new Set()) - const [checked, setChecked] = useState(0) - const [done, setDone] = useState(false) - const keyRef = useRef('') - - const checkPath = useCallback(async (path: string): Promise => { - try { - const resp = await fetch('/' + path, { method: 'HEAD' }) - if (!resp.ok) return path - // Vite SPA fallback serves index.html (text/html) for unknown assets - const ct = resp.headers.get('content-type') ?? '' - return ct.startsWith('image/') ? null : path - } catch { - return path - } - }, []) - - useEffect(() => { - const key = paths.join('\0') - if (key === keyRef.current) return - keyRef.current = key - - if (paths.length === 0) { - setMissing(new Set()) - setChecked(0) - setDone(true) - return - } - - let cancelled = false - setMissing(new Set()) - setChecked(0) - setDone(false) - - const missingAcc = new Set() - let completedCount = 0 - - for (const path of paths) { - checkPath(path).then((result) => { - if (cancelled) return - if (result !== null) missingAcc.add(result) - completedCount++ - setChecked(completedCount) - // Flush missing set on every update so table reflects state as it arrives - setMissing(new Set(missingAcc)) - if (completedCount === paths.length) setDone(true) - }) - } - - return () => { - cancelled = true - keyRef.current = '' // allow next effect invocation to restart (StrictMode safe) - } - }, [paths, checkPath]) - - return { missing, checked, total: paths.length, done } -}