| |
| |
| |
|
|
| 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); |
| const [thinking, setThinking] = useState(false); |
| const [toasts, setToasts] = useState([]); |
| const [armedCard, setArmedCard] = useState(null); |
| const [cardsPlayed, setCardsPlayed] = useState(0); |
| const [model, setModelState] = useState({ on: true, model: null, models: [], tts: false }); |
| const [aiLog, setAiLog] = useState([]); |
| const [started, setStarted] = useState(false); |
| const [peakSplash, setPeakSplash] = useState(false); |
| |
| |
| const [announceMuted, setAnnounceMuted] = useState(() => { |
| try { return localStorage.getItem('mlp_announce_muted') === '1'; } catch { return false; } |
| }); |
|
|
| |
| 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); |
| } |
| |
| 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), []); |
|
|
| |
| |
| const toggleAnnounceMute = useCallback(() => { |
| setAnnounceMuted((m) => { |
| const next = !m; |
| try { localStorage.setItem('mlp_announce_muted', next ? '1' : '0'); } catch { } |
| return next; |
| }); |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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 { |
| |
| |
| const staged = await apiTurn({ action: 'play_card', card, location }); |
| const played = cardsPlayedRef.current + 1; |
| setCardsPlayed(played); |
| setArmedCard(null); |
| audio.playSfx('card_play'); |
| |
| |
| |
| |
| if (played >= cap) { |
| const advanced = await apiTurn({ action: 'next_turn' }); |
| applyState(advanced); |
| return advanced; |
| } |
| applyState(staged); |
| return staged; |
| } catch (err) { |
| |
| |
| pushToast(err.message || 'card rejected'); |
| return null; |
| } finally { |
| setThinking(false); |
| } |
| }, [applyState, pushToast]); |
|
|
| const armCard = useCallback((card) => { |
| setArmedCard((cur) => { |
| const next = cur === card ? null : card; |
| if (next) audio.playSfx('click'); |
| return next; |
| }); |
| }, []); |
|
|
| |
| const handleStationTap = useCallback((stationName) => { |
| if (armedRef.current) { |
| playCard(armedRef.current, stationName); |
| } else { |
| audio.playStationClip(stationName); |
| } |
| }, [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]); |
|
|
| |
| useEffect(() => { |
| newGame(null); |
| setModel({}).catch(() => {}); |
| |
| }, []); |
|
|
| |
| const audioPrevRef = useRef(null); |
| 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 }; |
|
|
| |
| |
| |
| |
| 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(() => { }); |
| } |
|
|
| |
| audio.setRain(!!state.visual?.rain, state.phase === 'peak'); |
| |
| if (prev && prev.phase !== 'peak' && state.phase === 'peak') { |
| audio.playSfx('peak_sting'); |
| setPeakSplash(true); |
| } |
| |
| if ((!prev || !prev.over) && state.over) audio.playSfx(state.won ? 'loss' : 'win'); |
| |
| 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(() => ({ |
| |
| state, |
| thinking, |
| toasts, |
| pushToast, |
| armedCard, |
| armCard, |
| cardsPlayed, |
| cardCap, |
| newGame, |
| nextTurn, |
| playCard, |
| handleStationTap, |
| model, |
| setModel, |
| audio, |
| |
| aiLog, |
| started, |
| start, |
| peakSplash, |
| dismissPeakSplash, |
| announceMuted, |
| toggleAnnounceMute, |
| }), [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; |
| } |
|
|