diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa949a2..515c8aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* ` — session ` 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 diff --git a/CLAUDE.md b/CLAUDE.md index b5eac165..8b2c51f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/features/game-mode-codabench.md b/docs/features/game-mode-codabench.md index b6696263..1fc85fdc 100644 --- a/docs/features/game-mode-codabench.md +++ b/docs/features/game-mode-codabench.md @@ -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 - ` — session `, 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* ` — session ` 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, @@ -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 @@ -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:` → `redispatch_`, `ls:` → `load_shedding_`, + `rc:` → `curtail_` — 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, diff --git a/expert_backend/CLAUDE.md b/expert_backend/CLAUDE.md index e369e7cc..fa9e442a 100644 --- a/expert_backend/CLAUDE.md +++ b/expert_backend/CLAUDE.md @@ -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 ` — session ` 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. diff --git a/expert_backend/openapi.snapshot.json b/expert_backend/openapi.snapshot.json index 3a1c3e4a..f0a4e60e 100644 --- a/expert_backend/openapi.snapshot.json +++ b/expert_backend/openapi.snapshot.json @@ -858,6 +858,14 @@ "session_count": { "title": "Session Count", "type": "integer" + }, + "session_names": { + "default": [], + "items": { + "type": "string" + }, + "title": "Session Names", + "type": "array" } }, "required": [ diff --git a/expert_backend/services/game_solution_models.py b/expert_backend/services/game_solution_models.py index 75055679..273e78ff 100644 --- a/expert_backend/services/game_solution_models.py +++ b/expert_backend/services/game_solution_models.py @@ -102,3 +102,8 @@ class PlayerSessionsResponse(BaseModel): # Distinct sessions this player already recorded in the shared base; # seeds the default session name (` — session `). session_count: int + # The concrete distinct session names (sorted, case-insensitive). The + # config screen suggests the first free `session ` 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] = [] diff --git a/expert_backend/services/game_solutions.py b/expert_backend/services/game_solutions.py index d8663ab6..81c046ca 100644 --- a/expert_backend/services/game_solutions.py +++ b/expert_backend/services/game_solutions.py @@ -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 (`` — 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) 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 `` 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() @@ -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} diff --git a/expert_backend/tests/CLAUDE.md b/expert_backend/tests/CLAUDE.md index e22d570a..3386aa39 100644 --- a/expert_backend/tests/CLAUDE.md +++ b/expert_backend/tests/CLAUDE.md @@ -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`). diff --git a/expert_backend/tests/test_game_solutions.py b/expert_backend/tests/test_game_solutions.py index 9d84a983..71525be6 100644 --- a/expert_backend/tests/test_game_solutions.py +++ b/expert_backend/tests/test_game_solutions.py @@ -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": []} # --------------------------------------------------------------------------- @@ -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": []} diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7e084bc8..8425315e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 + @@ -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 @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14a05684..e0750155 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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]); diff --git a/frontend/src/game/GameConfigScreen.test.tsx b/frontend/src/game/GameConfigScreen.test.tsx index 6834892b..79f3f5ac 100644 --- a/frontend/src/game/GameConfigScreen.test.tsx +++ b/frontend/src/game/GameConfigScreen.test.tsx @@ -14,7 +14,10 @@ const mockApi = vi.hoisted(() => ({ getPlayerSessions: vi.fn() })); vi.mock('../api', () => ({ api: mockApi })); beforeEach(() => { - mockApi.getPlayerSessions.mockResolvedValue({ player: 'amarot', session_count: 2 }); + mockApi.getPlayerSessions.mockResolvedValue({ + player: 'amarot', session_count: 2, + session_names: ['amarot — session 1', 'amarot — session 2'], + }); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); @@ -46,7 +49,57 @@ describe('GameConfigScreen landing', () => { fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); await new Promise((r) => setTimeout(r, 400)); expect(sessionInput().value).toBe('My custom run'); - expect(mockApi.getPlayerSessions).not.toHaveBeenCalled(); + // The names are still fetched (they drive the duplicate block), but the + // typed name is preserved. + expect(mockApi.getPlayerSessions).toHaveBeenCalledWith('amarot'); + }); + + it('auto-suggests the first FREE index, skipping gaps in the recorded names', async () => { + // Recorded {1, 3} → the count-plus-one heuristic would collide on 3; the + // names-based suggestion fills the gap and picks 2. + mockApi.getPlayerSessions.mockResolvedValue({ + player: 'amarot', session_count: 2, + session_names: ['amarot — session 1', 'amarot — session 3'], + }); + render(); + fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); + await waitFor(() => expect(sessionInput().value).toBe('amarot — session 2')); + }); + + it('suggests the next index after finishing a session (no collision)', async () => { + // Sessions 1-3 already recorded → the next free index is 4, never a + // re-suggested 3 (the reported bug). + mockApi.getPlayerSessions.mockResolvedValue({ + player: 'amarot', session_count: 3, + session_names: ['amarot — session 1', 'amarot — session 2', 'amarot — session 3'], + }); + render(); + fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); + await waitFor(() => expect(sessionInput().value).toBe('amarot — session 4')); + }); + + it('blocks Start and shows an error when the name collides with an existing session', async () => { + const onStart = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); + await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); + // Type a name that already exists (case-insensitive). + fireEvent.change(sessionInput(), { target: { value: 'Amarot — Session 1' } }); + await waitFor(() => expect(screen.getByTestId('game-session-name-error')).toBeInTheDocument()); + expect(screen.getByTestId('game-start')).toBeDisabled(); + fireEvent.click(screen.getByTestId('game-start')); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('re-enables Start once the colliding name is changed to a free one', async () => { + render(); + fireEvent.change(screen.getByTestId('game-player'), { target: { value: 'amarot' } }); + await waitFor(() => expect(sessionInput().value).toBe('amarot — session 3')); + fireEvent.change(sessionInput(), { target: { value: 'amarot — session 2' } }); + await waitFor(() => expect(screen.getByTestId('game-start')).toBeDisabled()); + fireEvent.change(sessionInput(), { target: { value: 'amarot — session 9' } }); + expect(screen.queryByTestId('game-session-name-error')).toBeNull(); + expect(screen.getByTestId('game-start')).not.toBeDisabled(); }); it('lists the configured studies and shows the network preview', () => { diff --git a/frontend/src/game/GameConfigScreen.tsx b/frontend/src/game/GameConfigScreen.tsx index 8a5fc60b..e8582c59 100644 --- a/frontend/src/game/GameConfigScreen.tsx +++ b/frontend/src/game/GameConfigScreen.tsx @@ -5,7 +5,7 @@ // 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 { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api'; import { colors, space, text, radius } from '../styles/tokens'; import { @@ -58,12 +58,36 @@ const btn = (bg: string, fg: string): React.CSSProperties => ({ let customSeq = 0; +/** Lower-cased trimmed key used to compare session names case-insensitively. */ +const sessionKey = (name: string): string => name.trim().toLowerCase(); + +/** + * First ` — session ` name (n ≥ 1) not already taken. Scanning the + * concrete names — rather than count + 1 — fills gaps and never re-suggests an + * existing name when the recorded indices are non-contiguous (e.g. sessions + * {1, 3} → suggests 2, not a colliding 3). + */ +function firstFreeSessionName(player: string, taken: Set): string { + let n = 1; + while (taken.has(sessionKey(`${player} — session ${n}`))) n += 1; + return `${player} — session ${n}`; +} + export default function GameConfigScreen({ onStart }: GameConfigScreenProps) { const [player, setPlayer] = useState(''); const [sessionName, setSessionName] = useState(''); // True once the player types their own session name — stops the auto-default - // effect from overwriting it. + // effect from overwriting it. Mirrored in a ref so the debounced fetch + // callback reads the current value (its closure would otherwise be stale). const [sessionNameEdited, setSessionNameEdited] = useState(false); + const sessionNameEditedRef = useRef(sessionNameEdited); + useEffect(() => { sessionNameEditedRef.current = sessionNameEdited; }, [sessionNameEdited]); + // Session names this player already recorded in the shared base — the + // auto-suggest picks the first free index over them and Start is blocked + // when the entered name collides with one. + const [existingSessions, setExistingSessions] = useState([]); + const takenSessions = useMemo( + () => new Set(existingSessions.map(sessionKey)), [existingSessions]); const [minutes, setMinutes] = useState(5); const [seconds, setSeconds] = useState(0); const [maxActions, setMaxActions] = useState(3); @@ -86,23 +110,36 @@ export default function GameConfigScreen({ onStart }: GameConfigScreenProps) { const timerSeconds = minutes * 60 + seconds; - // Seed a default session name from the player handle + the next session - // index (counting the player's existing sessions in the shared base). Runs - // only until the player edits the name; debounced so it doesn't fire per - // keystroke. Falls back to "session 1" when the backend is unreachable - // (standalone build / offline), so the game stays playable. + // Fetch the player's existing session names from the shared base, then seed + // a default that skips every taken index (so a re-play never re-suggests a + // name that already exists). The fetch runs on every player change — even + // after the name is edited — because the names also drive the duplicate + // block below; only the auto-fill is gated on `sessionNameEdited`. Debounced + // so it doesn't fire per keystroke; falls back to "session 1" / no known + // sessions when the backend is unreachable (standalone build / offline). useEffect(() => { - if (sessionNameEdited) return; const name = player.trim(); if (!name) { - setSessionName(''); + setExistingSessions([]); + if (!sessionNameEditedRef.current) setSessionName(''); return; } let cancelled = false; const timer = window.setTimeout(() => { api.getPlayerSessions(name) - .then((r) => { if (!cancelled) setSessionName(`${name} — session ${r.session_count + 1}`); }) - .catch(() => { if (!cancelled) setSessionName(`${name} — session 1`); }); + .then((r) => { + if (cancelled) return; + const names = r.session_names ?? []; + setExistingSessions(names); + if (!sessionNameEditedRef.current) { + setSessionName(firstFreeSessionName(name, new Set(names.map(sessionKey)))); + } + }) + .catch(() => { + if (cancelled) return; + setExistingSessions([]); + if (!sessionNameEditedRef.current) setSessionName(`${name} — session 1`); + }); }, 350); return () => { cancelled = true; window.clearTimeout(timer); }; }, [player, sessionNameEdited]); @@ -155,7 +192,12 @@ export default function GameConfigScreen({ onStart }: GameConfigScreenProps) { : studies.length > 0 && studies.every((s) => s.networkPath && s.actionFilePath && s.contingencyElementId); const timerValid = timerSeconds >= 10; - const canStart = !needPlayer && studiesValid && timerValid; + // A session name the player already recorded blocks Start — the shared + // solution base keys retentions by session name, so a duplicate would + // merge two runs. Only meaningful once a name is entered. + const trimmedSession = sessionName.trim(); + const duplicateSession = trimmedSession.length > 0 && takenSessions.has(sessionKey(trimmedSession)); + const canStart = !needPlayer && studiesValid && timerValid && !duplicateSession; const start = () => { if (!canStart) return; @@ -177,9 +219,11 @@ export default function GameConfigScreen({ onStart }: GameConfigScreenProps) { const startHint = needPlayer ? 'Enter your player name to start.' - : (!studiesValid || !timerValid) - ? 'Some studies need attention — open ⚙ Configure settings below to fix them.' - : ''; + : duplicateSession + ? 'You already played a session with this name — pick another.' + : (!studiesValid || !timerValid) + ? 'Some studies need attention — open ⚙ Configure settings below to fix them.' + : ''; return (
- { setSessionName(e.target.value); setSessionNameEdited(true); }} /> + {duplicateSession && ( +

+ You already played “{trimmedSession}” — pick another name. +

+ )}