// GameProvider — React context holding the latest RenderState + request lifecycle. // The frontend is a PURE renderer: every field here comes from the server's RenderState. // THE SEAMS the HUD phase plugs into are the context fields listed in useGame() below. import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { turn as apiTurn, gameModel, announce } from './api.js'; import { audio } from './audio.js'; const GameContext = createContext(null); let toastSeq = 0; export function GameProvider({ children }) { const [state, setState] = useState(null); // latest RenderState (authoritative) const [thinking, setThinking] = useState(false); // a /turn POST is in flight const [toasts, setToasts] = useState([]); // [{id, text, kind}] 409s + errors const [armedCard, setArmedCard] = useState(null); // chaos card armed for station targeting const [cardsPlayed, setCardsPlayed] = useState(0); // staged this round (UI-side cap mirror) const [model, setModelState] = useState({ on: true, model: null, models: [], tts: false }); const [aiLog, setAiLog] = useState([]); // dispatcher history, newest first const [started, setStarted] = useState(false); // START splash dismissed? const [peakSplash, setPeakSplash] = useState(false); // PEAK RUSH announcement overlay showing? // Voice-announcement mute (separate from the global game mute): when true the dispatcher's // VoxCPM TTS is NOT requested at all — the @spaces.GPU call never fires, so it costs no quota. const [announceMuted, setAnnounceMuted] = useState(() => { try { return localStorage.getItem('mlp_announce_muted') === '1'; } catch { return false; } }); // refs so stable callbacks (used by the non-React Pixi scene) always see fresh values const stateRef = useRef(null); const armedRef = useRef(null); const thinkingRef = useRef(false); const cardsPlayedRef = useRef(0); const announceMutedRef = useRef(false); const modelRef = useRef(null); stateRef.current = state; armedRef.current = armedCard; thinkingRef.current = thinking; cardsPlayedRef.current = cardsPlayed; announceMutedRef.current = announceMuted; modelRef.current = model; const pushToast = useCallback((text, kind = 'error') => { const id = ++toastSeq; setToasts((t) => [...t, { id, text, kind }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500); }, []); const applyState = useCallback((next) => { const prev = stateRef.current; if (!prev || next.round !== prev.round || (prev.over && !next.over)) { setCardsPlayed(0); // new round (or new game) resets the per-round card stage count } // AI log history: one entry per dispatcher turn (state.ai is "the last turn"). const ai = next.ai || {}; const acted = ai.noop || ai.priority || (ai.actions || []).length > 0; if (acted && (!prev || next.round !== prev.round)) { setAiLog((log) => [{ round: next.round, ...ai }, ...log].slice(0, 60)); } setState(next); }, []); const doTurn = useCallback(async (body) => { setThinking(true); try { const next = await apiTurn(body); applyState(next); return next; } catch (err) { pushToast(err.message || 'request failed'); return null; } finally { setThinking(false); } }, [applyState, pushToast]); const newGame = useCallback((seed = null) => { setArmedCard(null); setCardsPlayed(0); setAiLog([]); setPeakSplash(false); return doTurn({ action: 'new_game', seed }); }, [doTurn]); const dismissPeakSplash = useCallback(() => setPeakSplash(false), []); /** Toggle the dispatcher voice announcements. Muted => the TTS request is never made, so the * VoxCPM @spaces.GPU call never fires and the player spends no GPU quota on audio. */ const toggleAnnounceMute = useCallback(() => { setAnnounceMuted((m) => { const next = !m; try { localStorage.setItem('mlp_announce_muted', next ? '1' : '0'); } catch { /* private mode */ } return next; }); }, []); /** START splash dismissal. Reuses the booted game unless a seed was requested. */ const start = useCallback((seed = null) => { setStarted(true); audio.playSfx('click'); if (seed != null || !stateRef.current) return newGame(seed); return Promise.resolve(stateRef.current); }, [newGame]); const nextTurn = useCallback(() => { if (thinkingRef.current) return Promise.resolve(null); setArmedCard(null); return doTurn({ action: 'next_turn' }); }, [doTurn]); /** Cards-per-round cap: always 1 (peak adds 2 trains for the AI instead of a 2nd card). */ const cardCap = 1; const playCard = useCallback(async (card, location) => { if (thinkingRef.current) return null; const cap = 1; if (cardsPlayedRef.current >= cap) { pushToast('card cap reached (1/round)', 'warn'); return null; } setThinking(true); try { // 1) stage the card — the server validates (available / station free / energy / cap) and // returns 409 on reject. play_card does NOT advance the sim; it only stages chaos. const staged = await apiTurn({ action: 'play_card', card, location }); const played = cardsPlayedRef.current + 1; setCardsPlayed(played); setArmedCard(null); // success: disarm audio.playSfx('card_play'); // 2) a successful card play IS the player's move for the round. Once they've used their // allotment (1 normally, 2 in peak), run the turn automatically — no Next Turn click. // In peak the FIRST of two cards still waits, so they can play a 2nd (or press Next Turn). // thinking stays true across both POSTs, so the HUD shows "dispatcher thinking…". if (played >= cap) { const advanced = await apiTurn({ action: 'next_turn' }); applyState(advanced); return advanced; } applyState(staged); // peak, first card: reflect the staged state, keep waiting for the 2nd return staged; } catch (err) { // 409 = rules engine rejected it (station busy / unaffordable / unavailable) — toast and // keep the card armed so the player can retarget another station. No turn advances. pushToast(err.message || 'card rejected'); return null; } finally { setThinking(false); } }, [applyState, pushToast]); const armCard = useCallback((card) => { setArmedCard((cur) => { const next = cur === card ? null : card; // click again to disarm if (next) audio.playSfx('click'); return next; }); }, []); /** Canvas -> React: a station was tapped. Armed card targets it; otherwise station SFX hook. */ const handleStationTap = useCallback((stationName) => { if (armedRef.current) { playCard(armedRef.current, stationName); } else { audio.playStationClip(stationName); // unarmed click = rotating station clip (audio seam) } }, [playCard]); const setModel = useCallback(async ({ on, model: m }) => { try { const body = {}; if (on !== undefined) body.on = on; if (m !== undefined) body.model = m; const res = await gameModel(body); setModelState({ on: res.on, model: res.model, models: res.models || [], tts: !!res.tts }); return res; } catch (err) { pushToast(err.message || 'model switch failed'); return null; } }, [pushToast]); // boot: start a game (and learn the model config) on first mount useEffect(() => { newGame(null); setModel({}).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ---- audio reactions (the audio.js seam — never touches the canvas) ---- const audioPrevRef = useRef(null); // { phase, over, notifSig } useEffect(() => { if (!state) return; const prev = audioPrevRef.current; const notifSig = `${state.round}|${(state.notifications || []) .map((n) => `${n.kind}:${n.text}`).join(';')}`; audioPrevRef.current = { phase: state.phase, over: state.over, notifSig, round: state.round }; // dispatcher VOICE announcement (VoxCPM2 on the Space) — fire once per NEW round, only when the // server has TTS enabled AND the player hasn't muted announcements. The /announce call IS the // GPU trigger, so a muted player never hits the @spaces.GPU endpoint (spends no quota). Async: // it never blocks the turn — the clip just plays when it's ready. const ann = (state.ai?.announcement || '').trim(); if (ann && (!prev || prev.round !== state.round) && modelRef.current?.tts && !announceMutedRef.current) { announce(state.round) .then((res) => { if (res?.audio) audio.playAnnouncementBase64(res.audio); }) .catch(() => { /* voice is best-effort ambience */ }); } // monsoon bed follows visual.rain (ducks louder during peak) audio.setRain(!!state.visual?.rain, state.phase === 'peak'); // peak entry: sting + the one-time PEAK RUSH announcement overlay if (prev && prev.phase !== 'peak' && state.phase === 'peak') { audio.playSfx('peak_sting'); setPeakSplash(true); } // win/loss sting — player-perspective: you (chaos) WIN when the line goes down (!state.won) if ((!prev || !prev.over) && state.over) audio.playSfx(state.won ? 'loss' : 'win'); // station-tied notifications -> that station's rotating clip (staggered, capped) if (!prev || prev.notifSig !== notifSig) { const stations = [...new Set( (state.notifications || []).map((n) => n.station).filter(Boolean), )].slice(0, 3); stations.forEach((s, i) => setTimeout(() => audio.playStationClip(s), 250 + i * 400)); } }, [state]); const value = useMemo(() => ({ // ---- HUD SEAMS (stable contract for the HUD phase) ---- state, // latest RenderState (null until first new_game returns) thinking, // true while a /turn POST is in flight ("dispatcher thinking…") toasts, // [{id, text, kind}] auto-expiring queue (409s land here) pushToast, // (text, kind?) -> add a toast armedCard, // chaos card id currently armed, or null armCard, // (card) toggle-arm a card cardsPlayed, // cards staged this round (UI cap mirror) cardCap, // 2 in peak else 1 newGame, // (seed?) -> POST new_game nextTurn, // () -> POST next_turn playCard, // (card, location) -> POST play_card handleStationTap, // canvas station-click entry (armed -> play, unarmed -> station SFX) model, // {on, model, models[]} dispatcher config setModel, // ({on?, model?}) -> POST /model audio, // the audio bus (mute toggle etc.) // ---- HUD-phase additions ---- aiLog, // [{round, priority, actions[], announcement, noop, confidence}] newest first started, // START splash dismissed? start, // (seed?) dismiss splash (newGame if seed given / no boot state) peakSplash, // PEAK RUSH announcement overlay showing? dismissPeakSplash,// () -> close the PEAK RUSH overlay announceMuted, // voice announcements muted? (gates the VoxCPM TTS request) toggleAnnounceMute,// () -> flip announcement mute (persisted) }), [state, thinking, toasts, pushToast, armedCard, armCard, cardsPlayed, cardCap, newGame, nextTurn, playCard, handleStationTap, model, setModel, aiLog, started, start, peakSplash, dismissPeakSplash, announceMuted, toggleAnnounceMute]); return {children}; } export function useGame() { const ctx = useContext(GameContext); if (!ctx) throw new Error('useGame must be used inside '); return ctx; }