diff --git a/docs/features/game-mode-codabench.md b/docs/features/game-mode-codabench.md index dfd1d5ee..b6696263 100644 --- a/docs/features/game-mode-codabench.md +++ b/docs/features/game-mode-codabench.md @@ -162,11 +162,20 @@ under a persistent root, exact-duplicate dedup, the player name as author). config-screen **Beginner assistance** checkbox on (default), the `GameHintsPanel` shows the top 5 for the current study in a collapsible in-play panel — best-effort like the log itself (no data or no backend → - the panel stays hidden). **Clicking a lever pre-fills the Inspect field** - (auto-zoom included) with the underlying element — `leverInspectTarget` - strips the `disco_`/`reco_` catalogue prefixes down to the branch id — - via a `gameBridge.registerInspector` / `requestInspect` pair, so App.tsx - stays decoupled from game internals (one guarded registration effect). + the panel stays hidden). Each lever is **actionable**: **single-click** + locates & inspects it — fills the Inspect field, centers the NAD (an + injection or coupling switch is resolved to its home VL through + `/api/element-voltage-levels`), and opens that substation's SLD; + **double-click** simulates the mapped action directly — a catalogue branch + disco/reco (`handleSimulateUnsimulatedAction`) or a coupling maneuver at the + resolved VL (`handleSimulateLever`), producing a card in the feed. A + magnitude-free injection / PST lever carries no self-contained action, so a + double-click degrades to inspect with a hint to set the amount in the SLD. + The game side maps a lever signature to a workspace-agnostic + `LeverInteraction` (`buildLeverInteraction`) and routes it via a + `gameBridge.registerLeverHandler` / `requestLeverInteraction` pair (App's + handler lives in the `useLeverInteraction` hook; single-click is deferred so + a double-click pre-empts it), so App.tsx stays decoupled from game internals. - **Flow** — `useGameSession` fires the log at every study commit, fire-and-forget (a failed log never blocks or breaks the game — the study simply carries no `solutionFeedback`). The session log is *derived* from diff --git a/expert_backend/services/network_service.py b/expert_backend/services/network_service.py index 5b533fc1..c566e595 100644 --- a/expert_backend/services/network_service.py +++ b/expert_backend/services/network_service.py @@ -495,7 +495,14 @@ def get_nominal_voltages(self) -> dict: } def get_element_voltage_levels(self, element_id: str) -> list: - """Resolve an equipment ID (line, transformer, or VL) to its voltage level IDs.""" + """Resolve an equipment ID to its voltage level IDs. + + Handles voltage levels, branches (lines / 2-winding transformers → + two VLs) and single-VL equipment: generators, loads and switches + (busbar couplers / disconnectors). The single-VL cases back the + Game-Mode lever hints, which must locate an injection or a coupling + switch on the network and open its substation SLD. + """ if not self.network: raise ValueError("Network not loaded") @@ -526,8 +533,39 @@ def get_element_voltage_levels(self, element_id: str) -> list: vls.add(row['voltage_level2_id']) return sorted(vls) + # Single-VL equipment: generators and loads each live in exactly one VL. + gen_vl = self._get_gen_vl_map().get(element_id) + if isinstance(gen_vl, str) and gen_vl: + return [gen_vl] + load_vl = self._get_load_vl_map().get(element_id) + if isinstance(load_vl, str) and load_vl: + return [load_vl] + + # Switches (busbar couplers / disconnectors) also belong to one VL. + switch_vl = self._get_switch_voltage_level(element_id) + if switch_vl: + return [switch_vl] + return [] + def _get_switch_voltage_level(self, switch_id: str) -> str | None: + """Return the voltage level ID a switch belongs to, or None. + + Queried on demand (not memoized) — the only caller is the interactive + element→VL resolution, hit on a user gesture, so a per-call + ``get_switches()`` is cheap enough and keeps the reset() surface small. + """ + try: + switches = self.network.get_switches() + except Exception: + return None + if (switches is None or not hasattr(switches, 'index') + or switch_id not in switches.index + or 'voltage_level_id' not in getattr(switches, 'columns', [])): + return None + vl = switches.loc[switch_id, 'voltage_level_id'] + return vl if isinstance(vl, str) and vl else None + def get_load_voltage_level(self, load_id: str) -> str | None: """Return the voltage level ID that a given load belongs to.""" return self._get_load_vl_map().get(load_id) diff --git a/expert_backend/tests/test_network_service.py b/expert_backend/tests/test_network_service.py index e1102562..2ab28b12 100644 --- a/expert_backend/tests/test_network_service.py +++ b/expert_backend/tests/test_network_service.py @@ -331,3 +331,80 @@ def test_transformer_resolves_to_two_vls(self, mock_network_service): def test_unknown_element_returns_empty(self, mock_network_service): result = mock_network_service.get_element_voltage_levels("NONEXISTENT") assert result == [] + + @staticmethod + def _service_with_injections_and_switches(): + """A NetworkService whose network also exposes generators, loads and + switches — the single-VL equipment the Game-Mode lever hints locate.""" + from expert_backend.services.network_service import NetworkService + + network = MagicMock() + network.get_voltage_levels.return_value = pd.DataFrame( + {"nominal_v": [400.0, 225.0]}, index=["VL1", "VL2"]) + network.get_lines.return_value = pd.DataFrame( + {"voltage_level1_id": [], "voltage_level2_id": []}, index=[]) + network.get_2_windings_transformers.return_value = pd.DataFrame( + {"voltage_level1_id": [], "voltage_level2_id": []}, index=[]) + network.get_generators.return_value = pd.DataFrame( + {"voltage_level_id": ["VL1"], "energy_source": ["WIND"], + "min_p": [0.0], "max_p": [100.0]}, + index=["GEN_1"]) + network.get_loads.return_value = pd.DataFrame( + {"voltage_level_id": ["VL2"]}, index=["LOAD_1"]) + network.get_switches.return_value = pd.DataFrame( + {"voltage_level_id": ["VL1"]}, index=["VL1_COUPL"]) + + service = NetworkService() + service.network = network + return service + + def test_generator_resolves_to_its_voltage_level(self): + service = self._service_with_injections_and_switches() + assert service.get_element_voltage_levels("GEN_1") == ["VL1"] + + def test_load_resolves_to_its_voltage_level(self): + service = self._service_with_injections_and_switches() + assert service.get_element_voltage_levels("LOAD_1") == ["VL2"] + + def test_switch_resolves_to_its_voltage_level(self): + service = self._service_with_injections_and_switches() + assert service.get_element_voltage_levels("VL1_COUPL") == ["VL1"] + + def test_unknown_injection_still_returns_empty(self): + service = self._service_with_injections_and_switches() + assert service.get_element_voltage_levels("GEN_NOPE") == [] + + def test_switch_lookup_tolerates_network_without_switches(self, mock_network_service): + """The default mock network has no get_switches DataFrame; resolution + must fall through to [] rather than raise.""" + assert mock_network_service.get_element_voltage_levels("SOME_SWITCH") == [] + + def test_switch_with_blank_voltage_level_returns_empty(self): + service = self._service_with_injections_and_switches() + service.network.get_switches.return_value = pd.DataFrame( + {"voltage_level_id": [""]}, index=["SW_BLANK"]) + assert service.get_element_voltage_levels("SW_BLANK") == [] + + def test_switch_with_non_string_voltage_level_returns_empty(self): + service = self._service_with_injections_and_switches() + service.network.get_switches.return_value = pd.DataFrame( + {"voltage_level_id": [float("nan")]}, index=["SW_NAN"]) + assert service.get_element_voltage_levels("SW_NAN") == [] + + def test_get_switches_error_is_tolerated(self): + """A network backend that raises on get_switches must not 500 the + resolution — the switch probe swallows it and falls through to [].""" + service = self._service_with_injections_and_switches() + service.network.get_switches.side_effect = RuntimeError("switch query failed") + assert service.get_element_voltage_levels("MYSTERY") == [] + + def test_switches_without_voltage_level_column_returns_empty(self): + service = self._service_with_injections_and_switches() + service.network.get_switches.return_value = pd.DataFrame( + {"kind": ["BREAKER"]}, index=["SW_NOCOL"]) + assert service.get_element_voltage_levels("SW_NOCOL") == [] + + def test_branch_resolution_precedes_injection_and_switch_lookup(self, mock_network_service): + """A line id resolves to its two VLs even though generators / loads / + switches are now probed too — branch precedence is preserved.""" + assert mock_network_service.get_element_voltage_levels("LINE_A") == ["VL1", "VL2"] diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 2b6f81d8..7e084bc8 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -60,9 +60,15 @@ frontend/ │ ├── useActionDiagramCache.ts # D4 sub-hook of useDiagrams — prime-then- │ │ # paint action-variant NAD cache (cleared on │ │ # contingency change) - │ ├── useManualSimulation.ts # D4 — the two operator "simulate now" flows - │ │ # (pin double-click + interactive SLD edit) + - │ │ # the shared SLD-edit state, extracted from App + │ ├── useManualSimulation.ts # D4 — the operator "simulate now" flows + │ │ # (pin double-click + interactive SLD edit + + │ │ # handleSimulateLever for a coupling lever + │ │ # hint) sharing one streamSimulateToCard + │ │ # helper, plus the shared SLD-edit state + │ ├── useLeverInteraction.ts # Game-Mode beginner-assistance wiring: + │ │ # registers the gameBridge lever handler — + │ │ # single-click locate+inspect (VL resolve + + │ │ # SLD open), double-click simulate │ ├── usePanZoom.ts # ViewBox state, zoom-to-element │ ├── useSldOverlay.ts # Single-Line-Diagram overlay │ ├── useSldTopologyEdit.ts # Interactive SLD edit (switches + @@ -569,11 +575,18 @@ exactly as before. most used by all players on the current contingency (`GET /api/game/lever-stats`), tagged voltage level / branch / generation / load. Best-effort like the solution log — no data or no - backend hides the panel. Clicking a lever pre-fills the Inspect field - (auto-zoom) with its element (`leverInspectTarget`) through the - `gameBridge.registerInspector` / `requestInspect` pair — App registers - `handleInspectQueryChange` in one `isGameMode()`-guarded effect and - never imports game internals beyond the bridge/solutionLog helpers. + backend hides the panel. A lever is actionable: **single-click** locates & + inspects it (fills the Inspect field, centers the NAD — resolving an + injection / coupling switch to its home VL via `/api/element-voltage-levels` + — and opens that substation's SLD), **double-click** simulates the mapped + action (a catalogue branch disco/reco, or a coupling maneuver at the + resolved VL; magnitude-free injection / PST levers degrade to inspect). The + game side turns a lever signature into a workspace-agnostic `LeverInteraction` + (`buildLeverInteraction` in `solutionLog.ts`) and routes it through the + `gameBridge.registerLeverHandler` / `requestLeverInteraction` pair; the App + handler lives in the `useLeverInteraction` hook (single-click deferred so a + double-click pre-empts it), so App.tsx still never imports game internals + beyond the bridge/solutionLog helpers. - **`presets.ts`** lists curated **solvable** fr225_400 contingencies; keep them winnable (the `scripts/game_mode/e2e_game_session.py` backend replay verifies `can_proceed=True`). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 980cb0c5..14a05684 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { useTiedTabsSync, type PZInstance } from './hooks/useTiedTabsSync'; import { useContingencyFetch } from './hooks/useContingencyFetch'; import { useDiagramHighlights } from './hooks/useDiagramHighlights'; import { useManualSimulation } from './hooks/useManualSimulation'; +import { useLeverInteraction } from './hooks/useLeverInteraction'; import { interactionLogger } from './utils/interactionLogger'; import { gameBridge } from './game/gameBridge'; import { buildChosenActionRecord } from './game/solutionLog'; @@ -587,6 +588,7 @@ function App() { sldPreviewLoading, handleSimulateUnsimulatedAction, handleSimulateSldEdit, + handleSimulateLever, } = useManualSimulation({ diagrams, selectedContingency, @@ -1287,13 +1289,6 @@ function App() { diagrams.setInspectQuery(q); }, [diagrams]); - // Game Mode: the hints panel pre-fills the Inspect field through the - // same handler the search box uses (auto-zoom included). - useEffect(() => { - if (!gameBridge.isGameMode()) return; - gameBridge.registerInspector(handleInspectQueryChange); - }, [handleInspectQueryChange]); - const handleToggleVoltageLevelNames = useCallback((show: boolean) => { interactionLogger.record('vl_names_toggled', { show }); setShowVoltageLevelNames(show); @@ -1317,6 +1312,12 @@ function App() { handleVlDoubleClick(selectedActionId || '', vlName); }, [handleVlDoubleClick, selectedActionId]); + // Game Mode beginner assistance: single-click a lever hint to locate + + // inspect it (opening the substation SLD), double-click to simulate it. + useLeverInteraction({ + diagrams, handleSimulateUnsimulatedAction, handleSimulateLever, handleVlOpen, + }); + // Clicking a (relabelled) feeder name on the SLD jumps to the far-end VL's // SLD, keeping the current sub-tab so the same contingency / overload stays // in view from the other extremity. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 34cfc0eb..ef47eb67 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -118,6 +118,13 @@ export const api = { ); return response.data; }, + getElementVoltageLevels: async (elementId: string): Promise<{ voltage_level_ids: string[] }> => { + const response = await axios.get<{ voltage_level_ids: string[] }>( + `${API_BASE_URL}/api/element-voltage-levels`, + { params: { element_id: elementId } } + ); + return response.data; + }, getNetworkDiagram: async (): Promise => { const res = await fetch(`${API_BASE_URL}/api/network-diagram?format=text`); if (!res.ok) { diff --git a/frontend/src/game/GameHintsPanel.test.tsx b/frontend/src/game/GameHintsPanel.test.tsx index d12de2ac..69fe4161 100644 --- a/frontend/src/game/GameHintsPanel.test.tsx +++ b/frontend/src/game/GameHintsPanel.test.tsx @@ -63,18 +63,70 @@ describe('GameHintsPanel', () => { expect(screen.getByText(/From 7 retained solutions by all players/)).toBeInTheDocument(); }); - it('pre-fills the Inspect field with the lever element on click', async () => { - const inspect = vi.spyOn(gameBridge, 'requestInspect'); + it('requests an inspect interaction after the single-click delay', async () => { + const lever = vi.spyOn(gameBridge, 'requestLeverInteraction'); getGameLeverStats.mockResolvedValue(stats()); render(); - await waitFor(() => expect(screen.getByText(/disco_LINE_A/)).toBeInTheDocument()); + await screen.findByText(/disco_LINE_A/); - // Catalogue disco lever → the embedded branch id reaches Inspect. + // Single-click is deferred so a double-click can pre-empt it. fireEvent.click(screen.getByText(/disco_LINE_A/)); - expect(inspect).toHaveBeenCalledWith('LINE_A'); - // Injection lever → the generator name itself. + expect(lever).not.toHaveBeenCalled(); + await waitFor(() => expect(lever).toHaveBeenCalledWith( + expect.objectContaining({ inspectQuery: 'LINE_A', simulate: { actionId: 'disco_LINE_A' } }), + 'inspect', + )); + }); + + it('requests a simulate interaction on double-click and cancels the pending inspect', async () => { + const lever = vi.spyOn(gameBridge, 'requestLeverInteraction'); + getGameLeverStats.mockResolvedValue(stats()); + render(); + await screen.findByText(/VL1_COUPL/); + + const target = screen.getByText(/VL1_COUPL/); + fireEvent.click(target); // schedules the deferred inspect + fireEvent.doubleClick(target); // pre-empts it and fires the simulate now + + expect(lever).toHaveBeenCalledTimes(1); + expect(lever).toHaveBeenCalledWith( + expect.objectContaining({ inspectQuery: 'VL1_COUPL', simulate: { switches: { VL1_COUPL: true } } }), + 'simulate', + ); + // Wait past the single-click delay — the pending inspect stays cancelled. + await new Promise((r) => setTimeout(r, 300)); + expect(lever).toHaveBeenCalledTimes(1); + }); + + it('single-clicks a magnitude-free injection lever to a simulate-less inspect', async () => { + const lever = vi.spyOn(gameBridge, 'requestLeverInteraction'); + getGameLeverStats.mockResolvedValue(stats()); + render(); + await screen.findByText(/G1/); + fireEvent.click(screen.getByText(/G1/)); - expect(inspect).toHaveBeenCalledWith('G1'); + await waitFor(() => expect(lever).toHaveBeenCalled()); + const [interaction, mode] = lever.mock.calls.at(-1)!; + expect(interaction).toMatchObject({ inspectQuery: 'G1', category: 'generation' }); + expect(interaction.simulate).toBeUndefined(); + expect(mode).toBe('inspect'); + }); + + it('double-clicks a catalogue branch lever to a simulate-by-action-id', async () => { + const lever = vi.spyOn(gameBridge, 'requestLeverInteraction'); + getGameLeverStats.mockResolvedValue(stats()); + render(); + await screen.findByText(/disco_LINE_A/); + + const target = screen.getByText(/disco_LINE_A/); + fireEvent.click(target); + fireEvent.doubleClick(target); + + expect(lever).toHaveBeenCalledTimes(1); + expect(lever).toHaveBeenCalledWith( + expect.objectContaining({ inspectQuery: 'LINE_A', simulate: { actionId: 'disco_LINE_A' } }), + 'simulate', + ); }); it('collapses to a pill and reopens', async () => { diff --git a/frontend/src/game/GameHintsPanel.tsx b/frontend/src/game/GameHintsPanel.tsx index 8c32bab6..2f385be3 100644 --- a/frontend/src/game/GameHintsPanel.tsx +++ b/frontend/src/game/GameHintsPanel.tsx @@ -5,15 +5,19 @@ // 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 { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '../api'; import { colors, space, text, radius } from '../styles/tokens'; import type { GameLeverStatWire } from '../types'; import { gameBridge } from './gameBridge'; import { GAME_HUD_HEIGHT } from './GameHud'; -import { leverInspectTarget } from './solutionLog'; +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; + interface GameHintsPanelProps { study: GameStudy; } @@ -47,6 +51,30 @@ export default function GameHintsPanel({ study }: GameHintsPanelProps) { const [total, setTotal] = useState(0); const [open, setOpen] = useState(true); + // 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; + gameBridge.requestLeverInteraction(buildLeverInteraction(lever), 'inspect'); + }, LEVER_SINGLE_CLICK_DELAY_MS); + }, []); + + const handleLeverDoubleClick = useCallback((lever: GameLeverStatWire) => { + if (clickTimerRef.current !== null) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + gameBridge.requestLeverInteraction(buildLeverInteraction(lever), 'simulate'); + }, []); + // No synchronous state reset here: GameShell keys the panel by study id, // so each study mounts a fresh panel with empty initial state. useEffect(() => { @@ -117,8 +145,9 @@ export default function GameHintsPanel({ study }: GameHintsPanelProps) { {levers.map((lever) => (