mumbai-local / frontend /src /GameProvider.jsx
geekwrestler's picture
Part 2: @app .api game endpoints + @gradio/client frontend (ZeroGPU auth-forwarded GPU calls)
8461a29 verified
Raw
History Blame Contribute Delete
11.9 kB
// 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 <GameContext.Provider value={value}>{children}</GameContext.Provider>;
}
export function useGame() {
const ctx = useContext(GameContext);
if (!ctx) throw new Error('useGame must be used inside <GameProvider>');
return ctx;
}