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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and the project (informally) follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Game Mode — unique session names + lever-simulation feedback

- **Duplicate session names are blocked.** `GET /api/game/player-sessions` now
returns the concrete `session_names` (sorted) alongside `session_count`. The
config screen auto-suggests the first *free* `<player> — session <n>` index
over the recorded names (so a re-play never re-suggests an existing name —
the old count-plus-one heuristic collided when the indices had gaps), and a
name that already exists **disables ▶ Start** with an inline warning (the
shared base keys retentions by session name, so a duplicate would merge two
runs).
- **Beginner-assistance lever hints show simulation feedback.** Double-clicking
a most-used lever now shows a per-row **⏳ simulating… → ✓ simulated**
transition and **blocks a redundant re-run** — "simulated" is read from a new
`simulatedActionIds` set on the game-bridge snapshot, so a lever also flips ✓
when its action arrives through the recommender's suggestions, and a failed
run self-clears. `gameBridge.requestLeverInteraction` is now awaitable so the
panel can drive the transition.
- **Injection levers simulate with the default incremental delta.** Redispatch
/ load-shedding / curtailment levers (`redispatch:` / `ls:` / `rc:`) map to
the backend dynamic-action id and simulate with no `target_mw`, so
`_create_dynamic_actions_if_needed` applies the default incremental injection
delta — a double-click runs them straight away instead of degrading to
inspect. Only PST / raw `gen_p:` / `load_p:` levers still degrade (a tap /
signed setpoint is required).

### Dependencies — lift the pypowsybl upper bound

- **`pypowsybl` is no longer capped below 1.15** (`pyproject.toml`). The earlier
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ guards the reductions documented in
| POST | `/api/compute-superposition` | Compute combined effect of two actions (superposition theorem) |
| POST | `/api/game/log-solution` | Capitalise a Game Mode retained proposition into the shared solution base; returns the novelty verdict (+bonus points) and per-action usage frequencies |
| GET | `/api/game/lever-stats` | Most-used unitary levers of a (network, contingency) context in the shared solution base — the Game Mode beginner-assistance hints (top-N, tagged voltage_level / branch / generation / load) |
| GET | `/api/game/player-sessions` | Count of distinct sessions a player already recorded in the shared base — seeds the default session name / index on the Game Mode config screen |
| GET | `/api/game/player-sessions` | Distinct sessions a player already recorded in the shared base — `session_count` + the concrete `session_names` (sorted). Seeds the default session name / index on the Game Mode config screen (first free index) and blocks a colliding name before Start |
| POST | `/api/save-session` | Save session folder with JSON snapshot + PDF copy |
| GET | `/api/list-sessions` | List available session folders in a directory |
| POST | `/api/load-session` | Load session JSON and restore PDFs |
Expand Down
42 changes: 32 additions & 10 deletions docs/features/game-mode-codabench.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ Launch the frontend with `?game=1` (e.g. `http://localhost:5173/?game=1`):
1. **Config screen** — a simple landing asks only what a participant needs to
start: your **player name** (required — it signs the solutions you retain in
the shared solution base, see below), the **session name** (auto-filled to
`<player> — session <n+1>`, where `n` counts the player's existing sessions
in the shared base via `GET /api/game/player-sessions`; editable), the
**beginner assistance** toggle, and **▶ Start session**. Below it, a preview
the first *free* `<player> — session <n>` index, scanning the player's
existing session names in the shared base via `GET /api/game/player-sessions`
so a re-play never re-suggests a name that already exists; editable but a
name that collides with one already recorded **blocks ▶ Start** with an
inline warning — the shared base keys retentions by session name, so a
duplicate would merge two runs), the **beginner assistance** toggle, and
**▶ Start session**. Below it, a preview
card lists the configured studies and shows a map of the network you'll work
on — the same thing the "Network (N)" NAD shows fully zoomed out (voltage
levels from `grid_layout.json` with the transmission lines drawn as edges,
Expand All @@ -40,7 +44,12 @@ Launch the frontend with `?game=1` (e.g. `http://localhost:5173/?game=1`):
assistance** enabled (config-screen checkbox, on by default), a
collapsible 💡 hints panel lists the **5 levers most used by all players**
on the current contingency, tagged by equipment family (voltage level /
branch / generation / load). **Star** the actions you
branch / generation / load). **Single-click** a lever to locate & inspect
it; **double-click** to simulate it — the row shows a **⏳ simulating…** →
**✓ simulated** transition and a second double-click is ignored once
simulated, so you never fire the same lever twice (a lever whose action
surfaces through the recommender's suggestions is marked ✓ too). **Star**
the actions you
commit to (the star is capped at the configured max). Click **Next study →**
(or let the timer expire) to advance. If your retained proposition turns
out to be **new** in the shared base, a 🌟 toast tells you right away and
Expand Down Expand Up @@ -167,13 +176,26 @@ under a persistent root, exact-duplicate dedup, the player name as author).
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
disco/reco (`handleSimulateUnsimulatedAction`), an **injection** (redispatch
/ load-shedding / curtailment) run with the backend's **default incremental
delta** (the lever signature maps to the backend dynamic-action id —
`redispatch:<g>` → `redispatch_<g>`, `ls:<l>` → `load_shedding_<l>`,
`rc:<g>` → `curtail_<g>` — simulated with no `target_mw`, so
`_create_dynamic_actions_if_needed` applies the default delta), or a coupling
maneuver at the resolved VL (`handleSimulateLever`), producing a card in the
feed. Only a **PST** lever or a raw `gen_p:` / `load_p:` setpoint lever still
degrades to inspect with a hint to set the amount in the SLD (a tap / signed
setpoint is needed). **Simulation feedback:** the double-clicked lever shows a
**⏳ simulating… → ✓ simulated** transition, and re-simulation is blocked —
the panel reads the App-published `simulatedActionIds` set on the game bridge
snapshot, so a lever also flips to ✓ 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; a coupling maneuver (whose fresh
`user_topo_*` id the snapshot can't match) is marked done locally on
completion. The game side maps a lever signature to a workspace-agnostic
`LeverInteraction` (`buildLeverInteraction`) and routes it via a
`gameBridge.registerLeverHandler` / `requestLeverInteraction` pair (App's
`gameBridge.registerLeverHandler` / `requestLeverInteraction` pair — now
awaitable so the panel can drive the simulating→simulated transition (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,
Expand Down
9 changes: 6 additions & 3 deletions expert_backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,12 @@ Game Mode:
(network, contingency) context, tagged by equipment family
(`voltage_level` / `branch` / `generation` / `load` / `other`).
Read-only store scan; feeds the Game Mode beginner-assistance panel.
- `GET /api/game/player-sessions` — count of distinct sessions a player
already recorded in the shared base (`player_session_count`); seeds the
default session name / index on the Game Mode config screen. Read-only.
- `GET /api/game/player-sessions` — distinct sessions a player already
recorded in the shared base (`player_session_count`): `session_count` +
the concrete `session_names` (sorted, case-insensitive). Seeds the default
session name (first FREE `<player> — session <n>` index) AND lets the config
screen block a colliding name before Start — a count-plus-one heuristic
re-suggests a taken name when the recorded indices have gaps. Read-only.

OS pickers & static:
- `GET /api/pick-path?type=file|dir` — spawns a tkinter subprocess.
Expand Down
8 changes: 8 additions & 0 deletions expert_backend/openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,14 @@
"session_count": {
"title": "Session Count",
"type": "integer"
},
"session_names": {
"default": [],
"items": {
"type": "string"
},
"title": "Session Names",
"type": "array"
}
},
"required": [
Expand Down
5 changes: 5 additions & 0 deletions expert_backend/services/game_solution_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,8 @@ class PlayerSessionsResponse(BaseModel):
# Distinct sessions this player already recorded in the shared base;
# seeds the default session name (`<player> — session <count+1>`).
session_count: int
# The concrete distinct session names (sorted, case-insensitive). The
# config screen suggests the first free `session <n>` index over these
# and blocks a name that already exists — a count-plus-one heuristic
# re-suggests a taken name when the recorded indices have gaps.
session_names: list[str] = []
23 changes: 15 additions & 8 deletions expert_backend/services/game_solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,18 +464,24 @@ def log_solution(payload: dict) -> dict:


def player_session_count(player: str) -> dict:
"""How many distinct sessions ``player`` has already recorded in the base.
"""Distinct sessions ``player`` has already recorded in the shared base.

Used to seed a default session name / index on the Game Mode config
screen (``<player> — session <n+1>``). A "session" is a distinct
``session_name`` under which the player signed at least one retained
solution; the player handle match is case-insensitive. Read-only scan of
the effective base — a player who never retained anything (or an empty
handle) counts as zero.
screen AND to reject a colliding name before "Start session". A
"session" is a distinct ``session_name`` under which the player signed at
least one retained solution; the player handle match is case-insensitive.
Read-only scan of the effective base — a player who never retained
anything (or an empty handle) yields an empty set.

Returns both the count and the concrete ``session_names`` (sorted) so the
config screen can (a) auto-suggest the first free ``session <n>`` index
even when the recorded names have gaps, and (b) block a name that already
exists — a count-plus-one heuristic alone re-suggests a taken name when
the indices are non-contiguous.
"""
name = (player or "").strip()
if not name:
return {"player": "", "session_count": 0}
return {"player": "", "session_count": 0, "session_names": []}
target = name.casefold()
base = _effective_base_dir()
sessions: set[str] = set()
Expand All @@ -491,4 +497,5 @@ def player_session_count(player: str) -> dict:
session_name = str(retention.get("session_name") or "").strip()
if session_name:
sessions.add(session_name)
return {"player": name, "session_count": len(sessions)}
ordered = sorted(sessions, key=str.casefold)
return {"player": name, "session_count": len(ordered), "session_names": ordered}
12 changes: 9 additions & 3 deletions expert_backend/tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,18 @@ covers the per-commit solution log: payload shape, skip-when-empty, async
feedback merge into the derived session log, novelty toast, fail-soft on a
rejected POST). `solutionLog.test.ts` covers the lever/signature computation
(MW-agnostic injection levers, manual-maneuver decomposition, catalogue
fallback) + wire payload/feedback mapping + bonus summation;
fallback) + the `buildLeverInteraction` mapping (injection levers →
dynamic-action id) + wire payload/feedback mapping + bonus summation;
`GameResults.test.tsx` covers the novelty-bonus / usage-frequency rendering;
`GameConfigScreen.test.tsx` covers the landing (incl. the first-free session
index over the recorded `session_names`, and the duplicate-name Start block);
`GameHintsPanel.test.tsx` covers the beginner-assistance lever hints
(category tags, collapse/reopen, hidden on empty base / failed fetch).
(category tags, collapse/reopen, hidden on empty base / failed fetch, the
simulating→simulated feedback, the re-run block, cross-marking from the
`simulatedActionIds` snapshot, and the injection-lever default-delta simulate).
The backend twin is `expert_backend/tests/test_game_solutions.py` (store,
novelty, dedup, frequencies, lever stats + categories, endpoints). The real-backend replay lives in
novelty, dedup, frequencies, lever stats + categories, player session names,
endpoints). The real-backend replay lives in
`scripts/game_mode/e2e_game_session.py` (not part of the Vitest suite; needs
pypowsybl + `expert_op4grid_recommender`).

Expand Down
26 changes: 20 additions & 6 deletions expert_backend/tests/test_game_solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,16 +330,29 @@ def test_player_session_count_counts_distinct_sessions(store_dir):
game_solutions.log_solution(payload(player="bob", session_name="s9"))

assert game_solutions.player_session_count("alice") == {
"player": "alice", "session_count": 2}
"player": "alice", "session_count": 2, "session_names": ["s1", "s2"]}
# Handle match is case-insensitive.
assert game_solutions.player_session_count("ALICE")["session_count"] == 2
assert game_solutions.player_session_count("bob")["session_count"] == 1
assert game_solutions.player_session_count("carol")["session_count"] == 0
assert game_solutions.player_session_count("bob") == {
"player": "bob", "session_count": 1, "session_names": ["s9"]}
assert game_solutions.player_session_count("carol") == {
"player": "carol", "session_count": 0, "session_names": []}


def test_player_session_names_are_returned_sorted(store_dir):
# Record out of natural order — the response is sorted case-insensitively
# so the config screen can pick the first free index / detect a collision.
for name in ("amarot — session 3", "amarot — session 1", "amarot — session 2"):
game_solutions.log_solution(payload(player="amarot", session_name=name))
result = game_solutions.player_session_count("amarot")
assert result["session_count"] == 3
assert result["session_names"] == [
"amarot — session 1", "amarot — session 2", "amarot — session 3"]


def test_player_session_count_empty_handle(store_dir):
assert game_solutions.player_session_count(" ") == {
"player": "", "session_count": 0}
"player": "", "session_count": 0, "session_names": []}


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -458,7 +471,8 @@ def test_player_sessions_endpoint(store_dir, client):
player="alice", session_name="s2", contingency_id="c2"))
resp = client.get("/api/game/player-sessions", params={"player": "alice"})
assert resp.status_code == 200
assert resp.json() == {"player": "alice", "session_count": 2}
assert resp.json() == {
"player": "alice", "session_count": 2, "session_names": ["s1", "s2"]}

empty = client.get("/api/game/player-sessions", params={"player": ""})
assert empty.json() == {"player": "", "session_count": 0}
assert empty.json() == {"player": "", "session_count": 0, "session_names": []}
27 changes: 20 additions & 7 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ frontend/
│ ├── useLeverInteraction.ts # Game-Mode beginner-assistance wiring:
│ │ # registers the gameBridge lever handler —
│ │ # single-click locate+inspect (VL resolve +
│ │ # SLD open), double-click simulate
│ │ # SLD open), double-click simulate (branch
│ │ # disco/reco + injection default-delta +
│ │ # coupling maneuver)
│ ├── usePanZoom.ts # ViewBox state, zoom-to-element
│ ├── useSldOverlay.ts # Single-Line-Diagram overlay
│ ├── useSldTopologyEdit.ts # Interactive SLD edit (switches +
Expand Down Expand Up @@ -552,7 +554,9 @@ exactly as before.

- **`gameBridge.ts`** is the decoupling singleton (mirrors
`interactionLogger`): `App` registers a study loader and publishes its
physical snapshot `{ baselineMaxRho, chosenActions }`; `GameShell` /
physical snapshot `{ baselineMaxRho, chosenActions, simulatedActionIds }`
(`simulatedActionIds` = every materialised action id, so the hints panel
can mark a lever "simulated"); `GameShell` /
`useGameSession` drive study loads, read results, and enforce the
≤ 3-action cap — so **`App.tsx` keeps only three `isGameMode()`-guarded
touch points** (`loadGameStudy`, the publish effect, and the
Expand All @@ -579,11 +583,20 @@ exactly as before.
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
action (a catalogue branch disco/reco, an **injection** — redispatch /
load-shedding / curtailment — run with the backend's default incremental
delta via its dynamic-action id, or a coupling maneuver at the resolved VL;
only PST / raw `gen_p:` / `load_p:` levers still degrade to inspect). The
panel shows a per-lever **⏳ simulating… → ✓ simulated** transition and
**blocks a redundant re-run** — "simulated" is read from the App-published
`simulatedActionIds` snapshot set (so a lever also flips ✓ when its action
arrives through the recommender's suggestions, and a failed run self-clears),
with a local fallback for coupling maneuvers whose fresh id the snapshot
can't match. The game side turns a lever signature into a workspace-agnostic
`LeverInteraction` (`buildLeverInteraction` in `solutionLog.ts` — injection
signatures map to the dynamic-action id) and routes it through the
`gameBridge.registerLeverHandler` / `requestLeverInteraction` pair (awaitable,
so the panel can drive the simulating→simulated transition); 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.
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,10 @@ function App() {
contingencyElementIds: selectedContingency,
baselineMaxRho,
chosenActions,
// Every action that has a materialised result — recommender-suggested,
// manually simulated, or lever-driven. The hints panel marks these
// levers "simulated" and blocks a redundant re-run.
simulatedActionIds: result ? Object.keys(result.actions) : [],
});
}, [result, selectedActionIds, selectedContingency, n1Diagram]);

Expand Down
Loading
Loading