// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import { useCallback, useEffect, useRef, useState } from 'react'; import { gameBridge, type GameStudySnapshot } from './gameBridge'; import { buildSessionLog } from './gameLog'; import type { GamePhase, GameSessionConfig, GameSessionLog, GameStudyResult, } from './types'; export interface GameSessionState { phase: GamePhase; config: GameSessionConfig | null; currentIndex: number; secondsLeft: number; snapshot: GameStudySnapshot; results: GameStudyResult[]; sessionLog: GameSessionLog | null; loadError: string | null; startSession: (config: GameSessionConfig) => void; /** Commit the current study (player clicked "Next") and advance. */ advance: () => void; /** Abandon the session and return to the config screen. */ quit: () => void; /** Retry loading the current study after a `loadError` (QW24). */ retryStudy: () => void; /** * End the session NOW with whatever studies are already committed and jump * to the results screen — so a mid-session backend failure doesn't destroy * the completed results (QW24). */ finishEarly: () => void; } /** Best (lowest) resulting loading among the player's chosen actions. */ function bestFinalRho(snapshot: GameStudySnapshot): number | null { const rhos = snapshot.chosenActions .map((a) => a.maxRho) .filter((r): r is number => typeof r === 'number'); return rhos.length ? Math.min(...rhos) : null; } export function useGameSession(): GameSessionState { const [phase, setPhase] = useState('config'); const [config, setConfig] = useState(null); const [currentIndex, setCurrentIndex] = useState(0); const [secondsLeft, setSecondsLeft] = useState(0); const [snapshot, setSnapshot] = useState(gameBridge.getSnapshot()); const [results, setResults] = useState([]); const [sessionLog, setSessionLog] = useState(null); const [loadError, setLoadError] = useState(null); // Refs mirror state for use inside timer callbacks / async loads, where // the captured closure would otherwise read stale values. const snapshotRef = useRef(snapshot); const configRef = useRef(config); const indexRef = useRef(currentIndex); const studyStartRef = useRef(0); const sessionStartRef = useRef(''); const resultsRef = useRef(results); const advancingRef = useRef(false); useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]); useEffect(() => { configRef.current = config; }, [config]); useEffect(() => { indexRef.current = currentIndex; }, [currentIndex]); useEffect(() => { resultsRef.current = results; }, [results]); // Mirror App's published physical snapshot into local state. useEffect(() => gameBridge.subscribe(setSnapshot), []); // Load a study by index and (re)start its timer. Index out of range ends // the session. const loadStudyAt = useCallback(async (index: number) => { const cfg = configRef.current; if (!cfg) return; if (index >= cfg.studies.length) { // Session complete — assemble the final log. const log = buildSessionLog( cfg, resultsRef.current, sessionStartRef.current, new Date().toISOString(), ); setSessionLog(log); setPhase('results'); return; } setPhase('loading'); setLoadError(null); gameBridge.reset(); setSnapshot(gameBridge.getSnapshot()); try { await gameBridge.loadStudy(cfg.studies[index]); studyStartRef.current = Date.now(); setSecondsLeft(cfg.timerSeconds); setPhase('playing'); } catch (err) { const e = err as { message?: string }; setLoadError(e.message || 'Failed to load study'); // Stay on the loading screen so the operator can retry / quit. } }, []); // Snapshot the current study into a result, then move on. const commitAndAdvance = useCallback((timedOut: boolean) => { if (advancingRef.current) return; // guard double-fire (timer + click) advancingRef.current = true; const cfg = configRef.current; const idx = indexRef.current; if (!cfg) { advancingRef.current = false; return; } const study = cfg.studies[idx]; const snap = snapshotRef.current; const finalMaxRho = bestFinalRho(snap); const solved = finalMaxRho != null && finalMaxRho < 1.0; const result: GameStudyResult = { studyId: study.id, label: study.label, contingencyElementId: study.contingencyElementId, contingencyLabel: study.contingencyLabel, startedAt: new Date(studyStartRef.current).toISOString(), endedAt: new Date().toISOString(), durationMs: Date.now() - studyStartRef.current, timedOut, timeLimitSeconds: cfg.timerSeconds, maxActions: cfg.maxActions, actionsChosen: snap.chosenActions.slice(0, cfg.maxActions), numActions: Math.min(snap.chosenActions.length, cfg.maxActions), baselineMaxRho: snap.baselineMaxRho, finalMaxRho, solved, }; // Update resultsRef SYNCHRONOUSLY here, not inside the setResults updater: // when this commit ends the session, loadStudyAt(nextIndex) reads // resultsRef.current to build the final log on the very next synchronous // line, before React has run the functional updater — so a ref assigned // inside the updater would still be stale and the last study would be // dropped from the results screen and the exported session log. const next = [...resultsRef.current, result]; resultsRef.current = next; setResults(next); const nextIndex = idx + 1; setCurrentIndex(nextIndex); indexRef.current = nextIndex; void loadStudyAt(nextIndex).finally(() => { advancingRef.current = false; }); }, [loadStudyAt]); // Per-second countdown while playing. Reaching zero auto-commits. useEffect(() => { if (phase !== 'playing') return; const id = window.setInterval(() => { setSecondsLeft((s) => { if (s <= 1) { window.clearInterval(id); commitAndAdvance(true); return 0; } return s - 1; }); }, 1000); return () => window.clearInterval(id); }, [phase, currentIndex, commitAndAdvance]); const startSession = useCallback((cfg: GameSessionConfig) => { gameBridge.setMaxActions(cfg.maxActions); setConfig(cfg); configRef.current = cfg; setResults([]); resultsRef.current = []; setSessionLog(null); setCurrentIndex(0); indexRef.current = 0; sessionStartRef.current = new Date().toISOString(); advancingRef.current = false; void loadStudyAt(0); }, [loadStudyAt]); const advance = useCallback(() => { if (phase !== 'playing') return; commitAndAdvance(false); }, [phase, commitAndAdvance]); const quit = useCallback(() => { setPhase('config'); setConfig(null); configRef.current = null; setResults([]); resultsRef.current = []; setSessionLog(null); setCurrentIndex(0); gameBridge.reset(); }, []); // Retry the current study after a load failure — reuses loadStudyAt's own // try/catch, so a transient failure clears loadError and reaches 'playing'. const retryStudy = useCallback(() => { void loadStudyAt(indexRef.current); }, [loadStudyAt]); // Finish the session with whatever is already committed. Mirrors the // session-complete branch of loadStudyAt but does NOT discard results — so a // study that can't load no longer nukes the completed ones. const finishEarly = useCallback(() => { const cfg = configRef.current; if (!cfg) return; const log = buildSessionLog( cfg, resultsRef.current, sessionStartRef.current, new Date().toISOString(), ); setSessionLog(log); setPhase('results'); }, []); return { phase, config, currentIndex, secondsLeft, snapshot, results, sessionLog, loadError, startSession, advance, quit, retryStudy, finishEarly, }; }