// 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 { api } from '../api'; import { colors, space, text, radius } from '../styles/tokens'; import type { GameLeverStatWire, LeverInteraction } from '../types'; import { gameBridge } from './gameBridge'; import { GAME_HUD_HEIGHT } from './GameHud'; import { buildLeverInteraction } from './solutionLog'; import type { GameStudy } from './types'; /** Single-click is deferred this long so a double-click can pre-empt it. * Mirrors the VL-disk interactions' `VL_SINGLE_CLICK_DELAY_MS`. */ const LEVER_SINGLE_CLICK_DELAY_MS = 250; /** Simulation state of one lever, driving the inline feedback + re-run block. */ type LeverSimStatus = 'idle' | 'simulating' | 'simulated'; interface GameHintsPanelProps { study: GameStudy; } /** Display wording for each lever equipment category. */ const LEVER_CATEGORY_LABELS: Record = { voltage_level: 'Voltage level', branch: 'Branch', generation: 'Generation', load: 'Load', other: 'Other', }; const CATEGORY_ICONS: Record = { voltage_level: 'πŸ”€', branch: 'πŸ”Œ', generation: '⚑', load: '🏠', other: 'πŸ”§', }; /** * Beginner-assistance hints β€” the 5 levers most used across ALL players' * retained solutions on the current contingency (shared solution base), * each tagged with the equipment family it acts on. Collapsible so the * hint never covers the workspace; fetched once per study, best-effort * (no data / no backend β†’ the panel stays hidden). */ export default function GameHintsPanel({ study }: GameHintsPanelProps) { const [levers, setLevers] = useState([]); const [total, setTotal] = useState(0); const [open, setOpen] = useState(true); // Simulation feedback state. `inFlight` marks a lever whose simulation is // running (the "simulating…" chip + re-run block); `localDone` marks // coupling maneuvers whose fresh `user_topo_*` id the workspace snapshot // can't match back to a lever. Action-id levers (branch disco/reco + // injections) are marked "simulated" straight from `simulatedIds`, the set // of materialised action ids the App publishes on the game bridge β€” so a // lever also flips to "simulated" when its action arrives through the // recommender's suggestions, and a failed run self-clears (its id never // enters the set) leaving the lever runnable again. const [inFlight, setInFlight] = useState>(() => new Set()); const [localDone, setLocalDone] = useState>(() => new Set()); const [simulatedIds, setSimulatedIds] = useState>( () => new Set(gameBridge.getSnapshot().simulatedActionIds)); useEffect(() => { setSimulatedIds(new Set(gameBridge.getSnapshot().simulatedActionIds)); return gameBridge.subscribe((snap) => setSimulatedIds(new Set(snap.simulatedActionIds))); }, []); const leverStatus = useCallback((lever: GameLeverStatWire): LeverSimStatus => { const sig = lever.signature; if (inFlight.has(sig)) return 'simulating'; if (localDone.has(sig)) return 'simulated'; const actionId = buildLeverInteraction(lever).simulate?.actionId; if (actionId && simulatedIds.has(actionId)) return 'simulated'; return 'idle'; }, [inFlight, localDone, simulatedIds]); // A single-click locates + inspects the lever; a double-click simulates it. // The single-click action is deferred so a double-click can pre-empt it β€” // otherwise the first click of a double-click would fire an inspect too. const clickTimerRef = useRef | null>(null); useEffect(() => () => { if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current); }, []); const handleLeverClick = useCallback((lever: GameLeverStatWire) => { if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current); clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; void gameBridge.requestLeverInteraction(buildLeverInteraction(lever), 'inspect'); }, LEVER_SINGLE_CLICK_DELAY_MS); }, []); const handleLeverDoubleClick = useCallback(async (lever: GameLeverStatWire) => { if (clickTimerRef.current !== null) { clearTimeout(clickTimerRef.current); clickTimerRef.current = null; } const interaction: LeverInteraction = buildLeverInteraction(lever); // Magnitude-free lever (PST / raw gen_p / load_p) β†’ nothing to simulate; // let the handler degrade to inspect without any status tracking. if (!interaction.simulate) { void gameBridge.requestLeverInteraction(interaction, 'simulate'); return; } // Already simulating or simulated β†’ ignore, so a second double-click can't // fire a duplicate simulation of the same lever. if (leverStatus(lever) !== 'idle') return; const sig = lever.signature; setInFlight((prev) => new Set(prev).add(sig)); try { await gameBridge.requestLeverInteraction(interaction, 'simulate'); // Coupling maneuvers register under a fresh user_topo__ id the // snapshot can't map back to this lever, so record completion locally. // Action-id levers rely on `simulatedIds` (self-correcting on failure). if (!interaction.simulate.actionId) { setLocalDone((prev) => new Set(prev).add(sig)); } } finally { setInFlight((prev) => { const next = new Set(prev); next.delete(sig); return next; }); } }, [leverStatus]); // No synchronous state reset here: GameShell keys the panel by study id, // so each study mounts a fresh panel with empty initial state. useEffect(() => { let cancelled = false; if (!study.contingencyElementId) return; api.getGameLeverStats(study.networkPath, study.contingencyElementId) .then((stats) => { if (cancelled) return; setLevers(stats.levers); setTotal(stats.total_retentions); }) .catch(() => { // Hints are best-effort: without a reachable base the panel hides. }); return () => { cancelled = true; }; }, [study.networkPath, study.contingencyElementId]); if (!levers.length) return null; const pill: React.CSSProperties = { position: 'fixed', top: GAME_HUD_HEIGHT + 8, right: space[3], zIndex: 9300, boxSizing: 'border-box', }; if (!open) { return ( ); } return (
πŸ’‘ Most-used levers here
    {levers.map((lever) => { const status = leverStatus(lever); const done = status === 'simulated'; const busy = status === 'simulating'; return (
  1. {' '}β€” {LEVER_CATEGORY_LABELS[lever.category]} Β· used {lever.count}Γ— {busy && ( ⏳ simulating… )} {done && ( βœ“ simulated )}
  2. ); })}

From {total} retained solution{total === 1 ? '' : 's'} by all players on this contingency. Click a lever to locate & inspect it; double-click to simulate it (once β€” a βœ“ marks levers already simulated).

); }