Co-Study4Grid / frontend /src /game /useGameSession.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
8.54 kB
// 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<GamePhase>('config');
const [config, setConfig] = useState<GameSessionConfig | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [secondsLeft, setSecondsLeft] = useState(0);
const [snapshot, setSnapshot] = useState<GameStudySnapshot>(gameBridge.getSnapshot());
const [results, setResults] = useState<GameStudyResult[]>([]);
const [sessionLog, setSessionLog] = useState<GameSessionLog | null>(null);
const [loadError, setLoadError] = useState<string | null>(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<number>(0);
const sessionStartRef = useRef<string>('');
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,
};
}