Spaces:
Sleeping
Trial-mode UX: live progress visibility + cosmetic fixes + tighter caps
Browse filesThree classes of issue surfaced during the trial playthrough:
(1) The 3-minute /advance felt completely silent in the UI even though the
LiveProgress component was mounted. Root cause: EventSource has no way to
attach an X-Trial-Password header, so the password gate rejected every
SSE connection silently. Backend now also accepts the password as a
?token= query param on the /games/{id}/stream endpoint, and the frontend
streamUrl appends it from localStorage. Live deliberation cards, the
phase rail, and the cost meter now render during advance.
(2) Cosmetic SPA bugs that shipped wrong turn counts:
- GameView.tsx:96 had `(turns?.length && 5) || 5` which always returned 5
regardless of the game's actual world.max_turns. Read straight from
state.world.max_turns now.
- Debrief.tsx:31 hardcoded `turn:5, max:5` for the shell header. Now
pulls from /games/{id}/state.
- i18n strings in en.ts / de.ts hardcoded "Five turns" and "Turn 1 of 5"
in newGame.scenario.footnote, newGame.ready.engineTail/modelOnly, and
debrief.dek. Replaced with turn-count-agnostic copy ("Multi-turn
dossiers", "the first briefing", "Every turn of deliberation").
(3) Trial-mode LLM-call shrinks beyond the existing 5-agent / 2-turn cap:
- Action coach MAX_ROUNDS now env-driven via _resolve_max_rounds(); trial
mode collapses to 1 round (override via WARGAME_TRIAL_COACH_ROUNDS).
- Critic / premortem / evaluator phases skip when WARGAME_TRIAL_MODE=1.
Saves 3 LLM calls per turn. They enrich the debrief but aren't part of
the named-character reveal that's the core demo. With these cuts a
full trial game (2 turns, 5 agents, 1-round coach, no eval phases)
drops to ~16 LLM calls vs ~25 before this commit.
5 new tests (349 passing). New env vars documented in app.py docstring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/src/api.ts +7 -1
- frontend/src/i18n/de.ts +4 -4
- frontend/src/i18n/en.ts +4 -4
- frontend/src/screens/Debrief.tsx +7 -3
- frontend/src/screens/GameView.tsx +3 -1
- wargame/agents/action_coach.py +30 -9
- wargame/api/app.py +10 -2
- wargame/engine/loop.py +23 -6
- wargame/tests/test_trial_safeguards.py +46 -0
|
@@ -184,7 +184,13 @@ export const api = {
|
|
| 184 |
req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
|
| 185 |
resumeGame: (id: string) =>
|
| 186 |
req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
};
|
| 189 |
|
| 190 |
export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
|
|
|
|
| 184 |
req<StateResponse>(`/games/${id}/pause`, { method: 'POST' }),
|
| 185 |
resumeGame: (id: string) =>
|
| 186 |
req<AdvanceResponse>(`/games/${id}/resume`, { method: 'POST' }),
|
| 187 |
+
// EventSource can't attach custom headers, so the trial password rides in
|
| 188 |
+
// a `?token=` query param instead. The backend middleware accepts either.
|
| 189 |
+
streamUrl: (id: string) => {
|
| 190 |
+
const pw = getPassword();
|
| 191 |
+
const qs = pw ? `?token=${encodeURIComponent(pw)}` : '';
|
| 192 |
+
return `${BASE}/games/${id}/stream${qs}`;
|
| 193 |
+
},
|
| 194 |
};
|
| 195 |
|
| 196 |
export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
|
|
@@ -56,14 +56,14 @@ export const de: Partial<Record<StringKey, string>> = {
|
|
| 56 |
"newGame.scenarios.loading": "Szenarien werden geladen…",
|
| 57 |
"newGame.scenario.role": "Rolle",
|
| 58 |
"newGame.scenario.agentCount": "{count} Berater am Vorstandstisch.",
|
| 59 |
-
"newGame.scenario.footnote": "
|
| 60 |
"newGame.scenario.vs": "vs {rival}",
|
| 61 |
"newGame.ready.eyebrow": "Bereit",
|
| 62 |
"newGame.ready.openCompany": "{company} öffnen",
|
| 63 |
"newGame.ready.pick": "Szenario oben auswählen",
|
| 64 |
"newGame.ready.engineWith": "Engine ·",
|
| 65 |
-
"newGame.ready.engineTail": "· generiert Briefing · ~30s
|
| 66 |
-
"newGame.ready.modelOnly": "Modell generiert Briefing · ~30s
|
| 67 |
"newGame.ready.openCta": "Fall öffnen →",
|
| 68 |
"newGame.ready.busy": "Briefing wird generiert…",
|
| 69 |
"newGame.lang.eyebrow": "Sprache",
|
|
@@ -365,7 +365,7 @@ export const de: Partial<Record<StringKey, string>> = {
|
|
| 365 |
"debrief.eyebrow.case": "Fall {short}…",
|
| 366 |
"debrief.eyebrow.tail": "Postmortem",
|
| 367 |
"debrief.title": "Das Urteil des Vorstands, aktenkundig.",
|
| 368 |
-
"debrief.dek": "
|
| 369 |
"debrief.errorTitle": "Debrief konnte nicht geladen werden",
|
| 370 |
"debrief.errorTag": "Fehler",
|
| 371 |
"debrief.loading": "Debrief wird geladen…",
|
|
|
|
| 56 |
"newGame.scenarios.loading": "Szenarien werden geladen…",
|
| 57 |
"newGame.scenario.role": "Rolle",
|
| 58 |
"newGame.scenario.agentCount": "{count} Berater am Vorstandstisch.",
|
| 59 |
+
"newGame.scenario.footnote": "Mehrrundige Dossiers · Briefings im Nebel des Krieges · juriergte Ergebnisse",
|
| 60 |
"newGame.scenario.vs": "vs {rival}",
|
| 61 |
"newGame.ready.eyebrow": "Bereit",
|
| 62 |
"newGame.ready.openCompany": "{company} öffnen",
|
| 63 |
"newGame.ready.pick": "Szenario oben auswählen",
|
| 64 |
"newGame.ready.engineWith": "Engine ·",
|
| 65 |
+
"newGame.ready.engineTail": "· generiert das erste Briefing · ~30s.",
|
| 66 |
+
"newGame.ready.modelOnly": "Modell generiert das erste Briefing · ~30s.",
|
| 67 |
"newGame.ready.openCta": "Fall öffnen →",
|
| 68 |
"newGame.ready.busy": "Briefing wird generiert…",
|
| 69 |
"newGame.lang.eyebrow": "Sprache",
|
|
|
|
| 365 |
"debrief.eyebrow.case": "Fall {short}…",
|
| 366 |
"debrief.eyebrow.tail": "Postmortem",
|
| 367 |
"debrief.title": "Das Urteil des Vorstands, aktenkundig.",
|
| 368 |
+
"debrief.dek": "Alle Beratungsrunden, das Rivalen-Drehbuch, der Verlauf der Welt und die Begründung des Schiedsrichters — in einem einzigen Bericht zusammengeführt. Nutzen Sie ihn, um Auftakte zu vergleichen, Anker zu prüfen und die Strategieklausur des nächsten Quartals vorzubereiten.",
|
| 369 |
"debrief.errorTitle": "Debrief konnte nicht geladen werden",
|
| 370 |
"debrief.errorTag": "Fehler",
|
| 371 |
"debrief.loading": "Debrief wird geladen…",
|
|
@@ -54,14 +54,14 @@ export const en = {
|
|
| 54 |
"newGame.scenarios.loading": "Loading scenarios…",
|
| 55 |
"newGame.scenario.role": "Role",
|
| 56 |
"newGame.scenario.agentCount": "{count} advisory agents at your board.",
|
| 57 |
-
"newGame.scenario.footnote": "
|
| 58 |
"newGame.scenario.vs": "vs {rival}",
|
| 59 |
"newGame.ready.eyebrow": "Ready",
|
| 60 |
"newGame.ready.openCompany": "Open {company}",
|
| 61 |
"newGame.ready.pick": "Pick a scenario above",
|
| 62 |
"newGame.ready.engineWith": "Engine ·",
|
| 63 |
-
"newGame.ready.engineTail": "· generates briefing · ~30s
|
| 64 |
-
"newGame.ready.modelOnly": "Model generates briefing · ~30s
|
| 65 |
"newGame.ready.openCta": "Open case →",
|
| 66 |
"newGame.ready.busy": "Generating briefing…",
|
| 67 |
"newGame.lang.eyebrow": "Language",
|
|
@@ -363,7 +363,7 @@ export const en = {
|
|
| 363 |
"debrief.eyebrow.case": "case {short}…",
|
| 364 |
"debrief.eyebrow.tail": "Post-mortem",
|
| 365 |
"debrief.title": "The board's verdict, on the record.",
|
| 366 |
-
"debrief.dek": "
|
| 367 |
"debrief.errorTitle": "Debrief could not be loaded",
|
| 368 |
"debrief.errorTag": "Error",
|
| 369 |
"debrief.loading": "Loading debrief…",
|
|
|
|
| 54 |
"newGame.scenarios.loading": "Loading scenarios…",
|
| 55 |
"newGame.scenario.role": "Role",
|
| 56 |
"newGame.scenario.agentCount": "{count} advisory agents at your board.",
|
| 57 |
+
"newGame.scenario.footnote": "Multi-turn dossiers · fog-of-war briefings · adjudicated outcomes",
|
| 58 |
"newGame.scenario.vs": "vs {rival}",
|
| 59 |
"newGame.ready.eyebrow": "Ready",
|
| 60 |
"newGame.ready.openCompany": "Open {company}",
|
| 61 |
"newGame.ready.pick": "Pick a scenario above",
|
| 62 |
"newGame.ready.engineWith": "Engine ·",
|
| 63 |
+
"newGame.ready.engineTail": "· generates the first briefing · ~30s.",
|
| 64 |
+
"newGame.ready.modelOnly": "Model generates the first briefing · ~30s.",
|
| 65 |
"newGame.ready.openCta": "Open case →",
|
| 66 |
"newGame.ready.busy": "Generating briefing…",
|
| 67 |
"newGame.lang.eyebrow": "Language",
|
|
|
|
| 363 |
"debrief.eyebrow.case": "case {short}…",
|
| 364 |
"debrief.eyebrow.tail": "Post-mortem",
|
| 365 |
"debrief.title": "The board's verdict, on the record.",
|
| 366 |
+
"debrief.dek": "Every turn of deliberation, the rival playbook, the world-state arc, and the referee's reasoning — collated into a single report. Use it to compare openings, debug anchors, and line up next quarter's strategic review.",
|
| 367 |
"debrief.errorTitle": "Debrief could not be loaded",
|
| 368 |
"debrief.errorTag": "Error",
|
| 369 |
"debrief.loading": "Loading debrief…",
|
|
@@ -14,6 +14,8 @@ export function Debrief({ id, onMeta }: Props) {
|
|
| 14 |
const { t } = useI18n();
|
| 15 |
const [md, setMd] = useState<string | null>(null);
|
| 16 |
const [err, setErr] = useState<string | null>(null);
|
|
|
|
|
|
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
api.debrief(id).then((r) => setMd(r.markdown)).catch((e) => setErr(String(e)));
|
|
@@ -23,14 +25,16 @@ export function Debrief({ id, onMeta }: Props) {
|
|
| 23 |
api.getState(id).then((s) => {
|
| 24 |
const lang = s.language as Language | undefined;
|
| 25 |
if (lang === 'en' || lang === 'de') setLanguage(lang);
|
|
|
|
|
|
|
| 26 |
}).catch(() => { /* non-fatal */ });
|
| 27 |
}, [id]);
|
| 28 |
|
| 29 |
useEffect(() => {
|
| 30 |
-
// Debrief is post-game
|
| 31 |
-
onMeta({ id, turn:
|
| 32 |
return () => onMeta(null);
|
| 33 |
-
}, [id, onMeta]);
|
| 34 |
|
| 35 |
return (
|
| 36 |
<>
|
|
|
|
| 14 |
const { t } = useI18n();
|
| 15 |
const [md, setMd] = useState<string | null>(null);
|
| 16 |
const [err, setErr] = useState<string | null>(null);
|
| 17 |
+
// Track real turn / max for the shell header; previously hardcoded to 5/5.
|
| 18 |
+
const [meta, setMeta] = useState<{ turn: number; max: number }>({ turn: 0, max: 0 });
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
api.debrief(id).then((r) => setMd(r.markdown)).catch((e) => setErr(String(e)));
|
|
|
|
| 25 |
api.getState(id).then((s) => {
|
| 26 |
const lang = s.language as Language | undefined;
|
| 27 |
if (lang === 'en' || lang === 'de') setLanguage(lang);
|
| 28 |
+
const w: any = s.world;
|
| 29 |
+
if (w) setMeta({ turn: Number(w.turn) || 0, max: Number(w.max_turns) || 0 });
|
| 30 |
}).catch(() => { /* non-fatal */ });
|
| 31 |
}, [id]);
|
| 32 |
|
| 33 |
useEffect(() => {
|
| 34 |
+
// Debrief is post-game; recon spent. Turn / max read from real state.
|
| 35 |
+
onMeta({ id, turn: meta.turn, max: meta.max, recon_remaining: 0 });
|
| 36 |
return () => onMeta(null);
|
| 37 |
+
}, [id, meta, onMeta]);
|
| 38 |
|
| 39 |
return (
|
| 40 |
<>
|
|
@@ -93,7 +93,9 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 93 |
setBriefing(br.briefing);
|
| 94 |
setBudget(br.recon_budget_remaining);
|
| 95 |
setTurn(br.turn);
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
setPhase((st.current_phase as Phase) || 'briefing');
|
| 98 |
// Paused mid-turn — keep the actionSent UI mounted so Resume is visible.
|
| 99 |
if (st?.status === 'paused') setActionSent(true);
|
|
|
|
| 93 |
setBriefing(br.briefing);
|
| 94 |
setBudget(br.recon_budget_remaining);
|
| 95 |
setTurn(br.turn);
|
| 96 |
+
// Read max_turns from the live world state — was previously a stale
|
| 97 |
+
// hardcoded 5 that ignored trial-mode caps.
|
| 98 |
+
setMaxTurns(st?.world?.max_turns ?? 5);
|
| 99 |
setPhase((st.current_phase as Phase) || 'briefing');
|
| 100 |
// Paused mid-turn — keep the actionSent UI mounted so Resume is visible.
|
| 101 |
if (st?.status === 'paused') setActionSent(true);
|
|
@@ -38,10 +38,31 @@ if TYPE_CHECKING:
|
|
| 38 |
from wargame.engine.state import Game
|
| 39 |
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
# ---------------------------------------------------------------------------
|
|
@@ -119,7 +140,7 @@ class ActionCoachSession(BaseModel):
|
|
| 119 |
ge=0,
|
| 120 |
description=(
|
| 121 |
"Number of user-message / coach-reply rounds consumed so far. "
|
| 122 |
-
f"Capped at {
|
| 123 |
),
|
| 124 |
)
|
| 125 |
status: Literal["gathering", "ready", "committed"] = "gathering"
|
|
@@ -346,12 +367,12 @@ def advance_session(
|
|
| 346 |
company_name=scenario.company_name,
|
| 347 |
rival_name=scenario.rival_name,
|
| 348 |
player_role=scenario.player_role,
|
| 349 |
-
max_rounds=str(
|
| 350 |
)
|
| 351 |
user = (
|
| 352 |
f"## Game state\n\n"
|
| 353 |
f"- Turn: **{game.world.turn} of {game.world.max_turns}**\n"
|
| 354 |
-
f"- Rounds used in this coach session: **{session.rounds_used} of {
|
| 355 |
f"- Public narrative: {game.world.public_narrative or '_(none yet)_'}\n"
|
| 356 |
f"- Media tone: {game.world.media_tone.value}\n\n"
|
| 357 |
f"## Current briefing\n\n{_briefing_summary_for_coach(game)}\n\n"
|
|
@@ -377,9 +398,9 @@ def advance_session(
|
|
| 377 |
)
|
| 378 |
|
| 379 |
# Force a downgrade if the round budget is exhausted but the model still
|
| 380 |
-
# wants to ask another question.
|
| 381 |
# round, so any 'asking' response on or after that round must commit.
|
| 382 |
-
if session.rounds_used >=
|
| 383 |
raw = _force_best_effort(raw, transcript=transcript)
|
| 384 |
|
| 385 |
# Belt-and-braces: if the model says 'ready' but didn't include a draft,
|
|
|
|
| 38 |
from wargame.engine.state import Game
|
| 39 |
|
| 40 |
|
| 41 |
+
import os as _os
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _resolve_max_rounds() -> int:
|
| 45 |
+
"""Conversational round cap before the coach must commit best-effort.
|
| 46 |
+
|
| 47 |
+
Trial mode collapses the coach to a single round (default 1) so a
|
| 48 |
+
leaked password can't drive the chatbot in a loop. Operators override
|
| 49 |
+
via WARGAME_TRIAL_COACH_ROUNDS. Outside trial mode the historical
|
| 50 |
+
behaviour (2 rounds) stands.
|
| 51 |
+
"""
|
| 52 |
+
if _os.getenv("WARGAME_TRIAL_MODE", "").strip() in ("1", "true", "yes", "on"):
|
| 53 |
+
try:
|
| 54 |
+
return max(int(_os.getenv("WARGAME_TRIAL_COACH_ROUNDS", "1")), 1)
|
| 55 |
+
except ValueError:
|
| 56 |
+
return 1
|
| 57 |
+
return 2
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Public constant kept for back-compat; readers should call
|
| 61 |
+
# `_resolve_max_rounds()` directly. The constant is recomputed at import
|
| 62 |
+
# time from env, which is fine for the single-process FastAPI server but
|
| 63 |
+
# means tests that flip env via monkeypatch must call _resolve_max_rounds()
|
| 64 |
+
# (or reload this module) to pick up the change.
|
| 65 |
+
MAX_ROUNDS = _resolve_max_rounds()
|
| 66 |
|
| 67 |
|
| 68 |
# ---------------------------------------------------------------------------
|
|
|
|
| 140 |
ge=0,
|
| 141 |
description=(
|
| 142 |
"Number of user-message / coach-reply rounds consumed so far. "
|
| 143 |
+
f"Capped at {_resolve_max_rounds()}; round {_resolve_max_rounds() + 1} forces ready."
|
| 144 |
),
|
| 145 |
)
|
| 146 |
status: Literal["gathering", "ready", "committed"] = "gathering"
|
|
|
|
| 367 |
company_name=scenario.company_name,
|
| 368 |
rival_name=scenario.rival_name,
|
| 369 |
player_role=scenario.player_role,
|
| 370 |
+
max_rounds=str(_resolve_max_rounds()),
|
| 371 |
)
|
| 372 |
user = (
|
| 373 |
f"## Game state\n\n"
|
| 374 |
f"- Turn: **{game.world.turn} of {game.world.max_turns}**\n"
|
| 375 |
+
f"- Rounds used in this coach session: **{session.rounds_used} of {_resolve_max_rounds()}**\n"
|
| 376 |
f"- Public narrative: {game.world.public_narrative or '_(none yet)_'}\n"
|
| 377 |
f"- Media tone: {game.world.media_tone.value}\n\n"
|
| 378 |
f"## Current briefing\n\n{_briefing_summary_for_coach(game)}\n\n"
|
|
|
|
| 398 |
)
|
| 399 |
|
| 400 |
# Force a downgrade if the round budget is exhausted but the model still
|
| 401 |
+
# wants to ask another question. The last allowed round is _resolve_max_rounds().
|
| 402 |
# round, so any 'asking' response on or after that round must commit.
|
| 403 |
+
if session.rounds_used >= _resolve_max_rounds() and raw.status == "asking":
|
| 404 |
raw = _force_best_effort(raw, transcript=transcript)
|
| 405 |
|
| 406 |
# Belt-and-braces: if the model says 'ready' but didn't include a draft,
|
|
@@ -120,8 +120,16 @@ def create_app() -> FastAPI:
|
|
| 120 |
# otherwise break CORS handshakes and HF Space healthchecks.
|
| 121 |
elif request.method == "OPTIONS" or request.url.path == "/healthz":
|
| 122 |
pass
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
# Trial-mode: refuse expensive endpoints up front so a leaked
|
| 126 |
# password can't drain the Azure budget through them.
|
| 127 |
if _is_disabled(request.method, request.url.path):
|
|
|
|
| 120 |
# otherwise break CORS handshakes and HF Space healthchecks.
|
| 121 |
elif request.method == "OPTIONS" or request.url.path == "/healthz":
|
| 122 |
pass
|
| 123 |
+
else:
|
| 124 |
+
# Browsers' EventSource API can't attach custom headers, so SSE
|
| 125 |
+
# endpoints accept the password as a `?token=` query param too.
|
| 126 |
+
# Anything else still requires the X-Trial-Password header.
|
| 127 |
+
supplied = (
|
| 128 |
+
request.headers.get("X-Trial-Password")
|
| 129 |
+
or request.query_params.get("token")
|
| 130 |
+
)
|
| 131 |
+
if supplied != password:
|
| 132 |
+
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
| 133 |
# Trial-mode: refuse expensive endpoints up front so a leaked
|
| 134 |
# password can't drain the Azure budget through them.
|
| 135 |
if _is_disabled(request.method, request.url.path):
|
|
@@ -13,8 +13,21 @@ from __future__ import annotations
|
|
| 13 |
|
| 14 |
import json
|
| 15 |
import logging
|
|
|
|
| 16 |
import threading
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from wargame.agents import constraint_checker, rival_ceo
|
| 19 |
from wargame.agents.base import begin_turn_usage, end_turn_usage, use_game_language
|
| 20 |
from wargame.agents.registry import ScenarioConfig, load_scenario
|
|
@@ -145,10 +158,9 @@ def new_game(
|
|
| 145 |
# can't drive a full 5-turn game (~80 LLM calls) in one click. Default
|
| 146 |
# is 1 — a single decision plus debrief. Operators can override via
|
| 147 |
# WARGAME_TRIAL_MAX_TURNS. No effect when WARGAME_TRIAL_MODE is unset.
|
| 148 |
-
|
| 149 |
-
if _os.getenv("WARGAME_TRIAL_MODE", "").strip() in ("1", "true", "yes", "on"):
|
| 150 |
try:
|
| 151 |
-
world.max_turns = max(int(
|
| 152 |
except ValueError:
|
| 153 |
world.max_turns = 1
|
| 154 |
|
|
@@ -341,7 +353,11 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
|
|
| 341 |
)
|
| 342 |
_check_pause(game, current)
|
| 343 |
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
events.publish(
|
| 346 |
PhaseEventKind.PHASE_STARTED, label="critic"
|
| 347 |
)
|
|
@@ -351,7 +367,7 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
|
|
| 351 |
)
|
| 352 |
_check_pause(game, current)
|
| 353 |
|
| 354 |
-
if not current.pre_mortems:
|
| 355 |
events.publish(
|
| 356 |
PhaseEventKind.PHASE_STARTED, label="premortem"
|
| 357 |
)
|
|
@@ -371,7 +387,8 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
|
|
| 371 |
)
|
| 372 |
_check_pause(game, current)
|
| 373 |
|
| 374 |
-
|
|
|
|
| 375 |
events.publish(
|
| 376 |
PhaseEventKind.PHASE_STARTED, label="evaluator"
|
| 377 |
)
|
|
|
|
| 13 |
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
+
import os
|
| 17 |
import threading
|
| 18 |
|
| 19 |
+
|
| 20 |
+
def _trial_mode_active() -> bool:
|
| 21 |
+
"""Whether WARGAME_TRIAL_MODE is set in any of the truthy variants.
|
| 22 |
+
|
| 23 |
+
Used by both new_game (max_turns cap) and advance (skip eval phases)
|
| 24 |
+
to gate trial-mode-only shrinks. Cheap to call — re-reads env every
|
| 25 |
+
time so an operator change takes effect without a restart.
|
| 26 |
+
"""
|
| 27 |
+
return os.getenv("WARGAME_TRIAL_MODE", "").strip() in (
|
| 28 |
+
"1", "true", "yes", "on",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
from wargame.agents import constraint_checker, rival_ceo
|
| 32 |
from wargame.agents.base import begin_turn_usage, end_turn_usage, use_game_language
|
| 33 |
from wargame.agents.registry import ScenarioConfig, load_scenario
|
|
|
|
| 158 |
# can't drive a full 5-turn game (~80 LLM calls) in one click. Default
|
| 159 |
# is 1 — a single decision plus debrief. Operators can override via
|
| 160 |
# WARGAME_TRIAL_MAX_TURNS. No effect when WARGAME_TRIAL_MODE is unset.
|
| 161 |
+
if _trial_mode_active():
|
|
|
|
| 162 |
try:
|
| 163 |
+
world.max_turns = max(int(os.getenv("WARGAME_TRIAL_MAX_TURNS", "1")), 1)
|
| 164 |
except ValueError:
|
| 165 |
world.max_turns = 1
|
| 166 |
|
|
|
|
| 353 |
)
|
| 354 |
_check_pause(game, current)
|
| 355 |
|
| 356 |
+
# Trial mode skips critic + premortem to cut ~2 LLM calls per
|
| 357 |
+
# turn. They enrich the debrief but aren't part of the core
|
| 358 |
+
# narrative reveal that matters for a quick demo.
|
| 359 |
+
_trial = _trial_mode_active()
|
| 360 |
+
if not _trial and not current.critic_assessments:
|
| 361 |
events.publish(
|
| 362 |
PhaseEventKind.PHASE_STARTED, label="critic"
|
| 363 |
)
|
|
|
|
| 367 |
)
|
| 368 |
_check_pause(game, current)
|
| 369 |
|
| 370 |
+
if not _trial and not current.pre_mortems:
|
| 371 |
events.publish(
|
| 372 |
PhaseEventKind.PHASE_STARTED, label="premortem"
|
| 373 |
)
|
|
|
|
| 387 |
)
|
| 388 |
_check_pause(game, current)
|
| 389 |
|
| 390 |
+
# Evaluator scores are also skipped in trial mode (1 LLM call).
|
| 391 |
+
if not _trial and not current.evaluator_scores and current.verdicts:
|
| 392 |
events.publish(
|
| 393 |
PhaseEventKind.PHASE_STARTED, label="evaluator"
|
| 394 |
)
|
|
@@ -94,6 +94,52 @@ def test_trial_mode_keeps_core_loop_open(monkeypatch):
|
|
| 94 |
assert r.status_code == 200, f"{path} should be reachable in trial mode"
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def test_password_gate_still_runs_in_trial_mode(monkeypatch):
|
| 98 |
"""Disabling endpoints must not bypass the password gate — otherwise a
|
| 99 |
503 leaks the existence of those routes to unauthenticated callers."""
|
|
|
|
| 94 |
assert r.status_code == 200, f"{path} should be reachable in trial mode"
|
| 95 |
|
| 96 |
|
| 97 |
+
def test_sse_stream_accepts_token_query_param(monkeypatch, tmp_path):
|
| 98 |
+
"""EventSource can't attach custom headers, so the SSE endpoint must
|
| 99 |
+
accept the password as `?token=...`. Header still works too."""
|
| 100 |
+
monkeypatch.setenv("WARGAME_TRIAL_PASSWORD", "letmein")
|
| 101 |
+
monkeypatch.setenv("WARGAME_GAMES_DIR", str(tmp_path))
|
| 102 |
+
app = _fresh_app(monkeypatch)
|
| 103 |
+
client = TestClient(app)
|
| 104 |
+
# No password -> 401
|
| 105 |
+
r = client.get("/games/nope/stream")
|
| 106 |
+
assert r.status_code == 401
|
| 107 |
+
# Header -> reaches the route, which 404s on unknown game
|
| 108 |
+
r = client.get("/games/nope/stream", headers={"X-Trial-Password": "letmein"})
|
| 109 |
+
assert r.status_code == 404
|
| 110 |
+
# Query param -> same as header
|
| 111 |
+
r = client.get("/games/nope/stream?token=letmein")
|
| 112 |
+
assert r.status_code == 404
|
| 113 |
+
# Wrong query param -> 401
|
| 114 |
+
r = client.get("/games/nope/stream?token=wrong")
|
| 115 |
+
assert r.status_code == 401
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_action_coach_max_rounds_in_trial_mode(monkeypatch):
|
| 119 |
+
monkeypatch.setenv("WARGAME_TRIAL_MODE", "1")
|
| 120 |
+
from wargame.agents import action_coach
|
| 121 |
+
|
| 122 |
+
importlib.reload(action_coach)
|
| 123 |
+
assert action_coach._resolve_max_rounds() == 1
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_action_coach_max_rounds_default(monkeypatch):
|
| 127 |
+
monkeypatch.delenv("WARGAME_TRIAL_MODE", raising=False)
|
| 128 |
+
from wargame.agents import action_coach
|
| 129 |
+
|
| 130 |
+
importlib.reload(action_coach)
|
| 131 |
+
assert action_coach._resolve_max_rounds() == 2
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_action_coach_max_rounds_overridable(monkeypatch):
|
| 135 |
+
monkeypatch.setenv("WARGAME_TRIAL_MODE", "1")
|
| 136 |
+
monkeypatch.setenv("WARGAME_TRIAL_COACH_ROUNDS", "3")
|
| 137 |
+
from wargame.agents import action_coach
|
| 138 |
+
|
| 139 |
+
importlib.reload(action_coach)
|
| 140 |
+
assert action_coach._resolve_max_rounds() == 3
|
| 141 |
+
|
| 142 |
+
|
| 143 |
def test_password_gate_still_runs_in_trial_mode(monkeypatch):
|
| 144 |
"""Disabling endpoints must not bypass the password gate — otherwise a
|
| 145 |
503 leaks the existence of those routes to unauthenticated callers."""
|