Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/features/game-mode-codabench.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion expert_backend/services/network_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions expert_backend/tests/test_network_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
29 changes: 21 additions & 8 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down Expand Up @@ -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`).
Expand Down
15 changes: 8 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -587,6 +588,7 @@ function App() {
sldPreviewLoading,
handleSimulateUnsimulatedAction,
handleSimulateSldEdit,
handleSimulateLever,
} = useManualSimulation({
diagrams,
selectedContingency,
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiagramData & { svg: string }> => {
const res = await fetch(`${API_BASE_URL}/api/network-diagram?format=text`);
if (!res.ok) {
Expand Down
66 changes: 59 additions & 7 deletions frontend/src/game/GameHintsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<GameHintsPanel study={STUDY} />);
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(<GameHintsPanel study={STUDY} />);
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(<GameHintsPanel study={STUDY} />);
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(<GameHintsPanel study={STUDY} />);
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 () => {
Expand Down
Loading
Loading