misack Claude Opus 4.7 (1M context) commited on
Commit
bd956f0
·
1 Parent(s): ecf16e0

Round 6: German localization (en + de)

Browse files

Backend (backend-i18n teammate):
- Game.language: Literal["en","de"] = "en"; default keeps old snapshots loadable.
- language_directive(lang) appended to every call_structured user message;
system prefix stays byte-identical so the prompt cache stays warm.
- use_game_language() context manager + ContextVar; loop.py + orchestrator
pin language across all entry points; ThreadPoolExecutor in
run_deliberations now propagates context (fixes a latent issue for
per-game model selection too).
- CLI: wargame new --language en|de; wargame state shows the language.
- API: NewGameRequest.language + StateResponse.language.
- _delta_summary, _metrics_table, _recent_events placeholders localized
via _t(en, de) helper.
- 13 new tests including a system-prompt-byte-identical-across-languages
invariant that protects the cache.

Frontend (frontend-i18n teammate):
- New frontend/src/i18n/ module — ~95-line shim with useI18n() hook,
setLanguage, getLanguage, {name} interpolation, English fallback,
localStorage persistence. No new deps (no i18next).
- 329 keys in en.ts (source of truth) + de.ts (full parity).
- Every visible string across NewGame, GameView, SetupCase, Debrief,
GamesList, Shell, LiveProgress extracted; LiveProgress step labels
mapped from English phase event labels per the contract.
- Inline EN | Deutsch picker on NewGame; subtle EN | DE toggle in
Shell top-right for the global preference.
- GameView and Debrief follow state.language on mount.

301 tests pass (288 baseline + 13 new). Frontend npm run build green
(414 KB JS gzip 123 KB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/README.md CHANGED
@@ -79,6 +79,16 @@ npm run preview # serve dist on :4173
79
  npm run lint # tsc --noEmit
80
  ```
81
 
 
 
 
 
 
 
 
 
 
 
82
  ## SSE smoke test (Round 5b)
83
 
84
  `scripts/sse-smoke.mjs` connects to `/games/<id>/stream` and prints each
 
79
  npm run lint # tsc --noEmit
80
  ```
81
 
82
+ ## i18n smoke test (Round 6)
83
+
84
+ Strings live in `src/i18n/en.ts` (source of truth) and `src/i18n/de.ts`. Switch
85
+ languages via the `EN | DE` toggle in the top-right of the app shell, or via the
86
+ inline pills on the New Case screen (which also pin the language for the new
87
+ game). Manual smoke check: open `/new`, switch to **Deutsch** — the page copy
88
+ flips to German; switch back — it returns to English. Missing keys fall back to
89
+ the English string at runtime, so untranslated UI is the visible regression
90
+ signal.
91
+
92
  ## SSE smoke test (Round 5b)
93
 
94
  `scripts/sse-smoke.mjs` connects to `/games/<id>/stream` and prints each
frontend/src/App.tsx CHANGED
@@ -5,6 +5,7 @@ import { NewGame } from './screens/NewGame';
5
  import { SetupCase } from './screens/SetupCase';
6
  import { GameView } from './screens/GameView';
7
  import { Debrief } from './screens/Debrief';
 
8
 
9
  type Route =
10
  | { name: 'list' }
@@ -32,6 +33,7 @@ export function navigate(to: string) {
32
  export function App() {
33
  const [route, setRoute] = useState<Route>(parseHash);
34
  const [gameMeta, setGameMeta] = useState<{ id: string; turn: number; max: number; recon_remaining: number } | null>(null);
 
35
  useEffect(() => {
36
  const h = () => setRoute(parseHash());
37
  window.addEventListener('hashchange', h);
@@ -52,8 +54,8 @@ export function App() {
52
  {route.name === 'debrief' && <Debrief id={route.id} onMeta={setGameMeta} />}
53
  </main>
54
  <footer className="nh-footer">
55
- <span>Norhard · War — Board Console</span>
56
- <span className="mono">MVP frontend</span>
57
  </footer>
58
  </>
59
  );
 
5
  import { SetupCase } from './screens/SetupCase';
6
  import { GameView } from './screens/GameView';
7
  import { Debrief } from './screens/Debrief';
8
+ import { useI18n } from './i18n';
9
 
10
  type Route =
11
  | { name: 'list' }
 
33
  export function App() {
34
  const [route, setRoute] = useState<Route>(parseHash);
35
  const [gameMeta, setGameMeta] = useState<{ id: string; turn: number; max: number; recon_remaining: number } | null>(null);
36
+ const { t } = useI18n();
37
  useEffect(() => {
38
  const h = () => setRoute(parseHash());
39
  window.addEventListener('hashchange', h);
 
54
  {route.name === 'debrief' && <Debrief id={route.id} onMeta={setGameMeta} />}
55
  </main>
56
  <footer className="nh-footer">
57
+ <span>{t('shell.footer.tagline')}</span>
58
+ <span className="mono">{t('shell.footer.build')}</span>
59
  </footer>
60
  </>
61
  );
frontend/src/api.ts CHANGED
@@ -24,12 +24,15 @@ export const api = {
24
  scenario_id: string,
25
  model?: string,
26
  role_model_overrides?: Record<string, string>,
 
27
  ) => {
28
  const body: Record<string, unknown> = { scenario_id };
29
  if (model) body.model = model;
30
  if (role_model_overrides && Object.keys(role_model_overrides).length > 0) {
31
  body.role_model_overrides = role_model_overrides;
32
  }
 
 
33
  return req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, {
34
  method: 'POST',
35
  body: JSON.stringify(body),
@@ -168,7 +171,7 @@ export interface ReconResponseEnvelope { game_id: string; recon_budget_remaining
168
  export interface AdvanceResponse { game_id: string; status: string; turn_resolved: number; next_turn?: number; briefing?: Briefing }
169
  export interface DebriefResponse { game_id: string; status: string; markdown: string }
170
  export interface MetricsTimelineResponse { game_id: string; points: { turn: number; metrics: { key: string; label: string; unit: string; value: number; delta: number }[] }[] }
171
- export interface StateResponse { id: string; scenario_id: string; status: string; world: any; recon_budget_remaining: number; current_phase: string; turns: any[] }
172
  export interface SetupMessage { role: 'user' | 'setup_agent'; content: string; at: string }
173
  export interface SetupSession {
174
  session_id: string;
 
24
  scenario_id: string,
25
  model?: string,
26
  role_model_overrides?: Record<string, string>,
27
+ language?: 'en' | 'de',
28
  ) => {
29
  const body: Record<string, unknown> = { scenario_id };
30
  if (model) body.model = model;
31
  if (role_model_overrides && Object.keys(role_model_overrides).length > 0) {
32
  body.role_model_overrides = role_model_overrides;
33
  }
34
+ // ---- i18n (round 6) ----
35
+ if (language) body.language = language;
36
  return req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, {
37
  method: 'POST',
38
  body: JSON.stringify(body),
 
171
  export interface AdvanceResponse { game_id: string; status: string; turn_resolved: number; next_turn?: number; briefing?: Briefing }
172
  export interface DebriefResponse { game_id: string; status: string; markdown: string }
173
  export interface MetricsTimelineResponse { game_id: string; points: { turn: number; metrics: { key: string; label: string; unit: string; value: number; delta: number }[] }[] }
174
+ export interface StateResponse { id: string; scenario_id: string; status: string; world: any; recon_budget_remaining: number; current_phase: string; turns: any[]; language?: 'en' | 'de' }
175
  export interface SetupMessage { role: 'user' | 'setup_agent'; content: string; at: string }
176
  export interface SetupSession {
177
  session_id: string;
frontend/src/components/LiveProgress.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useEffect, useRef, useState } from 'react';
2
  import { api, PhaseEvent, VerdictOutcome } from '../api';
3
  import { Spinner } from './Shell';
 
4
 
5
  // ---------------------------------------------------------------------------
6
  // LiveProgress — SSE-driven in-turn UX. Mounts an EventSource on the game's
@@ -13,20 +14,21 @@ type StepState = 'idle' | 'running' | 'done' | 'failed';
13
 
14
  interface Step {
15
  key: string;
16
- label: string;
 
17
  // Backend `phase_started`/`phase_completed` `label` values that map to
18
  // this rail step. Match is case-insensitive substring.
19
  match: string[];
20
  }
21
 
22
  const STEPS: Step[] = [
23
- { key: 'deliberation', label: 'Deliberation', match: ['deliberation'] },
24
- { key: 'critic', label: 'Critic', match: ['critic'] },
25
- { key: 'premortem', label: 'Pre-mortem', match: ['premortem', 'pre-mortem'] },
26
- { key: 'adjudication', label: 'Adjudication', match: ['adjudication'] },
27
- { key: 'evaluator', label: 'Evaluator', match: ['evaluator'] },
28
- { key: 'world_update', label: 'World update', match: ['world_update', 'world update'] },
29
- { key: 'next_briefing', label: 'Next briefing', match: ['briefing'] },
30
  ];
31
 
32
  interface DeliberationRow {
@@ -53,6 +55,7 @@ interface Props {
53
  export function LiveProgress({
54
  gameId, onPaused, onBriefingReady, onAdvanceComplete,
55
  }: Props) {
 
56
  // Phase rail state — keyed by Step.key.
57
  const [stepStates, setStepStates] = useState<Record<string, StepState>>(
58
  () => Object.fromEntries(STEPS.map((s) => [s.key, 'idle'])),
@@ -185,7 +188,7 @@ export function LiveProgress({
185
  setPausedAt(null);
186
  return;
187
  case 'error':
188
- setStreamErr(ev.label || 'Engine reported an error.');
189
  return;
190
  default:
191
  return;
@@ -225,7 +228,7 @@ export function LiveProgress({
225
  if (cancelled) return;
226
  failedReconnectsRef.current += 1;
227
  if (failedReconnectsRef.current >= 2) {
228
- setStreamErr('Live stream disconnected — refresh to reconnect.');
229
  es.close();
230
  sseRef.current = null;
231
  // Schedule a single retry after ~2 s. Browser handles intermediate
@@ -263,22 +266,22 @@ export function LiveProgress({
263
  <section className="nh-live">
264
  <div className="nh-live__hd">
265
  <div>
266
- <span className="eyebrow">Live · turn in flight</span>
267
  <h3 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
268
  {pausedAt
269
- ? `Paused — ${pausedAt}`
270
- : 'Tracking deliberations and verdicts in real time.'}
271
  </h3>
272
  </div>
273
- <div className="nh-live__cost mono" aria-label="cumulative cost">
274
- ${cost.toFixed(4)} <span className="fg-3">spent</span>
275
  </div>
276
  </div>
277
 
278
  {pausedAt && (
279
  <div className="nh-live__paused">
280
- <span className="eyebrow">Paused</span>
281
- <span className="fg-2">Paused at end of {pausedAt}. Resume to continue.</span>
282
  </div>
283
  )}
284
 
@@ -286,13 +289,13 @@ export function LiveProgress({
286
  <div className="nh-live__err mono">{streamErr}</div>
287
  )}
288
 
289
- <ol className="nh-live__rail" aria-label="phase progress">
290
  {STEPS.map((s) => {
291
  const st = stepStates[s.key];
292
  return (
293
  <li key={s.key} className={`nh-live__pill ${st}`}>
294
  <span className="dot" aria-hidden />
295
- <span className="t">{s.label}</span>
296
  <span className="st mono">
297
  {st === 'idle' && '—'}
298
  {st === 'running' && <Spinner />}
@@ -306,12 +309,12 @@ export function LiveProgress({
306
 
307
  <div className="nh-live__grid">
308
  <div className="nh-live__panel">
309
- <span className="eyebrow">Deliberations</span>
310
  {deliberations.length === 0 ? (
311
  <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
312
  {stepStates.deliberation === 'running'
313
- ? <><Spinner /> Agents thinking…</>
314
- : 'No agent deliberations yet.'}
315
  </p>
316
  ) : (
317
  <ul className="nh-live__delibs">
@@ -319,10 +322,14 @@ export function LiveProgress({
319
  <li key={d.id}>
320
  <div className="who mono">{d.label}</div>
321
  {d.expects && (
322
- <div className="ex"><span className="k mono">expects</span> {d.expects}</div>
 
 
323
  )}
324
  {d.fears && (
325
- <div className="fe"><span className="k mono">fears</span> {d.fears}</div>
 
 
326
  )}
327
  </li>
328
  ))}
@@ -331,10 +338,10 @@ export function LiveProgress({
331
  </div>
332
 
333
  <div className="nh-live__panel">
334
- <span className="eyebrow">Verdicts</span>
335
  {verdicts.length === 0 ? (
336
  <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
337
- No verdicts adjudicated yet this turn.
338
  </p>
339
  ) : (
340
  <ul className="nh-live__verdicts">
@@ -342,9 +349,9 @@ export function LiveProgress({
342
  <li key={v.id} className={`nh-live__verdict outcome-${outcomeToClass(v.outcome)}`}>
343
  <div className="hd">
344
  <span className="actor mono">{v.actor}</span>
345
- <span className="pl mono">P{v.plausibility}</span>
346
  </div>
347
- <div className="oc mono">{outcomeLabel(v.outcome)}</div>
348
  </li>
349
  ))}
350
  </ul>
@@ -365,12 +372,15 @@ function outcomeToClass(o: VerdictOutcome): string {
365
  }
366
  }
367
 
368
- function outcomeLabel(o: VerdictOutcome): string {
 
 
 
369
  switch (o) {
370
- case 'effect_major': return 'effect · major';
371
- case 'effect_minor': return 'effect · minor';
372
- case 'no_effect': return 'no effect';
373
- case 'backfires': return 'backfires';
374
  default: return o;
375
  }
376
  }
 
1
  import { useEffect, useRef, useState } from 'react';
2
  import { api, PhaseEvent, VerdictOutcome } from '../api';
3
  import { Spinner } from './Shell';
4
+ import { useI18n, type StringKey } from '../i18n';
5
 
6
  // ---------------------------------------------------------------------------
7
  // LiveProgress — SSE-driven in-turn UX. Mounts an EventSource on the game's
 
14
 
15
  interface Step {
16
  key: string;
17
+ // i18n key for the step label.
18
+ labelKey: StringKey;
19
  // Backend `phase_started`/`phase_completed` `label` values that map to
20
  // this rail step. Match is case-insensitive substring.
21
  match: string[];
22
  }
23
 
24
  const STEPS: Step[] = [
25
+ { key: 'deliberation', labelKey: 'liveProgress.steps.deliberation', match: ['deliberation'] },
26
+ { key: 'critic', labelKey: 'liveProgress.steps.critic', match: ['critic'] },
27
+ { key: 'premortem', labelKey: 'liveProgress.steps.premortem', match: ['premortem', 'pre-mortem'] },
28
+ { key: 'adjudication', labelKey: 'liveProgress.steps.adjudication', match: ['adjudication'] },
29
+ { key: 'evaluator', labelKey: 'liveProgress.steps.evaluator', match: ['evaluator'] },
30
+ { key: 'world_update', labelKey: 'liveProgress.steps.worldUpdate', match: ['world_update', 'world update'] },
31
+ { key: 'next_briefing', labelKey: 'liveProgress.steps.nextBriefing', match: ['briefing'] },
32
  ];
33
 
34
  interface DeliberationRow {
 
55
  export function LiveProgress({
56
  gameId, onPaused, onBriefingReady, onAdvanceComplete,
57
  }: Props) {
58
+ const { t } = useI18n();
59
  // Phase rail state — keyed by Step.key.
60
  const [stepStates, setStepStates] = useState<Record<string, StepState>>(
61
  () => Object.fromEntries(STEPS.map((s) => [s.key, 'idle'])),
 
188
  setPausedAt(null);
189
  return;
190
  case 'error':
191
+ setStreamErr(ev.label || t('liveProgress.engineErr.fallback'));
192
  return;
193
  default:
194
  return;
 
228
  if (cancelled) return;
229
  failedReconnectsRef.current += 1;
230
  if (failedReconnectsRef.current >= 2) {
231
+ setStreamErr(t('liveProgress.streamErr.disconnected'));
232
  es.close();
233
  sseRef.current = null;
234
  // Schedule a single retry after ~2 s. Browser handles intermediate
 
266
  <section className="nh-live">
267
  <div className="nh-live__hd">
268
  <div>
269
+ <span className="eyebrow">{t('liveProgress.eyebrow')}</span>
270
  <h3 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
271
  {pausedAt
272
+ ? t('liveProgress.titlePaused', { label: pausedAt })
273
+ : t('liveProgress.titleDefault')}
274
  </h3>
275
  </div>
276
+ <div className="nh-live__cost mono" aria-label={t('liveProgress.cost.aria')}>
277
+ ${cost.toFixed(4)} <span className="fg-3">{t('liveProgress.cost.suffix')}</span>
278
  </div>
279
  </div>
280
 
281
  {pausedAt && (
282
  <div className="nh-live__paused">
283
+ <span className="eyebrow">{t('liveProgress.paused.eyebrow')}</span>
284
+ <span className="fg-2">{t('liveProgress.paused.body', { label: pausedAt })}</span>
285
  </div>
286
  )}
287
 
 
289
  <div className="nh-live__err mono">{streamErr}</div>
290
  )}
291
 
292
+ <ol className="nh-live__rail" aria-label={t('liveProgress.rail.aria')}>
293
  {STEPS.map((s) => {
294
  const st = stepStates[s.key];
295
  return (
296
  <li key={s.key} className={`nh-live__pill ${st}`}>
297
  <span className="dot" aria-hidden />
298
+ <span className="t">{t(s.labelKey)}</span>
299
  <span className="st mono">
300
  {st === 'idle' && '—'}
301
  {st === 'running' && <Spinner />}
 
309
 
310
  <div className="nh-live__grid">
311
  <div className="nh-live__panel">
312
+ <span className="eyebrow">{t('liveProgress.deliberations')}</span>
313
  {deliberations.length === 0 ? (
314
  <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
315
  {stepStates.deliberation === 'running'
316
+ ? <><Spinner /> {t('liveProgress.deliberations.thinking')}</>
317
+ : t('liveProgress.deliberations.empty')}
318
  </p>
319
  ) : (
320
  <ul className="nh-live__delibs">
 
322
  <li key={d.id}>
323
  <div className="who mono">{d.label}</div>
324
  {d.expects && (
325
+ <div className="ex">
326
+ <span className="k mono">{t('liveProgress.deliberations.expects')}</span> {d.expects}
327
+ </div>
328
  )}
329
  {d.fears && (
330
+ <div className="fe">
331
+ <span className="k mono">{t('liveProgress.deliberations.fears')}</span> {d.fears}
332
+ </div>
333
  )}
334
  </li>
335
  ))}
 
338
  </div>
339
 
340
  <div className="nh-live__panel">
341
+ <span className="eyebrow">{t('liveProgress.verdicts')}</span>
342
  {verdicts.length === 0 ? (
343
  <p className="fg-3 body-sm" style={{ marginTop: 8 }}>
344
+ {t('liveProgress.verdicts.empty')}
345
  </p>
346
  ) : (
347
  <ul className="nh-live__verdicts">
 
349
  <li key={v.id} className={`nh-live__verdict outcome-${outcomeToClass(v.outcome)}`}>
350
  <div className="hd">
351
  <span className="actor mono">{v.actor}</span>
352
+ <span className="pl mono">{t('liveProgress.verdict.plausibility', { n: v.plausibility })}</span>
353
  </div>
354
+ <div className="oc mono">{outcomeLabel(v.outcome, t)}</div>
355
  </li>
356
  ))}
357
  </ul>
 
372
  }
373
  }
374
 
375
+ function outcomeLabel(
376
+ o: VerdictOutcome,
377
+ t: (k: StringKey, vars?: Record<string, string | number>) => string,
378
+ ): string {
379
  switch (o) {
380
+ case 'effect_major': return t('liveProgress.verdict.outcome.major');
381
+ case 'effect_minor': return t('liveProgress.verdict.outcome.minor');
382
+ case 'no_effect': return t('liveProgress.verdict.outcome.noEffect');
383
+ case 'backfires': return t('liveProgress.verdict.outcome.backfires');
384
  default: return o;
385
  }
386
  }
frontend/src/components/Shell.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { ReactNode } from 'react';
 
2
 
3
  // Shell components ported from components/Shell.jsx — class names preserved
4
  // so the paper/ink/oxblood Board Console look is identical.
@@ -9,46 +10,124 @@ export function TopBar(props: {
9
  onNav: (r: string) => void;
10
  }) {
11
  const { game, route, onNav } = props;
 
12
  return (
13
  <header className="nh-topbar">
14
  <div className="nh-topbar__l">
15
  <button className="nh-brand" onClick={() => onNav('list')}>
16
- Norhard<em>/</em>War<span className="nh-brand__tag">Board Console</span>
17
  </button>
18
  <nav className="nh-nav">
19
- <button className={route === 'list' ? 'on' : ''} onClick={() => onNav('list')}>Cases</button>
20
- <button className={route === 'new' ? 'on' : ''} onClick={() => onNav('new')}>New case</button>
 
 
 
 
21
  </nav>
22
  </div>
23
- {game && (
24
- <div className="nh-status">
25
- <span>Case <b className="mono">{game.id.slice(0, 4)}…{game.id.slice(-4)}</b></span>
26
- <span className="nh-sep"></span>
27
- <span>Turn <b>{game.turn} / {game.max}</b></span>
28
- <span className="nh-sep"></span>
29
- <span>Recon <b>{game.recon_remaining} / 3</b></span>
30
- </div>
31
- )}
 
 
 
32
  </header>
33
  );
34
  }
35
 
36
- const PHASES = [
37
- { n: '01', t: 'Briefing' },
38
- { n: '02', t: 'Recon' },
39
- { n: '03', t: 'Action' },
40
- { n: '04', t: 'Deliberation' },
41
- { n: '05', t: 'Adjudication' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  ];
43
 
44
  export function PhaseRail({ current }: { current: 'briefing' | 'recon' | 'action' | 'deliberation' | 'adjudication' }) {
 
45
  const order = ['briefing', 'recon', 'action', 'deliberation', 'adjudication'];
46
  const idx = order.indexOf(current);
47
  return (
48
  <div className="nh-rail">
49
  {PHASES.map((p, i) => (
50
  <div key={p.n} className={`nh-rail__p ${i < idx ? 'done' : ''} ${i === idx ? 'active' : ''}`}>
51
- <span className="n mono">{p.n}</span><span className="t">{p.t}</span>
 
52
  </div>
53
  ))}
54
  </div>
@@ -60,9 +139,10 @@ export function Dossier(props: {
60
  className?: string; fog?: boolean; inverse?: boolean;
61
  }) {
62
  const { tag, title, children, footnote, className = '', fog = false, inverse = false } = props;
 
63
  return (
64
  <article className={`nh-dossier ${fog ? 'fog' : ''} ${inverse ? 'inverse' : ''} ${className}`}>
65
- {fog && <span className="nh-dossier__fog">Fog of war</span>}
66
  {tag && <span className="nh-dossier__tag">{tag}</span>}
67
  {title && <h3 className="nh-dossier__h">{title}</h3>}
68
  {children}
 
1
  import { ReactNode } from 'react';
2
+ import { useI18n, type Language } from '../i18n';
3
 
4
  // Shell components ported from components/Shell.jsx — class names preserved
5
  // so the paper/ink/oxblood Board Console look is identical.
 
10
  onNav: (r: string) => void;
11
  }) {
12
  const { game, route, onNav } = props;
13
+ const { t, lang, setLang } = useI18n();
14
  return (
15
  <header className="nh-topbar">
16
  <div className="nh-topbar__l">
17
  <button className="nh-brand" onClick={() => onNav('list')}>
18
+ Norhard<em>/</em>War<span className="nh-brand__tag">{t('shell.brand.tag')}</span>
19
  </button>
20
  <nav className="nh-nav">
21
+ <button className={route === 'list' ? 'on' : ''} onClick={() => onNav('list')}>
22
+ {t('shell.nav.cases')}
23
+ </button>
24
+ <button className={route === 'new' ? 'on' : ''} onClick={() => onNav('new')}>
25
+ {t('shell.nav.newCase')}
26
+ </button>
27
  </nav>
28
  </div>
29
+ <div className="nh-topbar__r" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
30
+ {game && (
31
+ <div className="nh-status">
32
+ <span>{t('shell.status.case')} <b className="mono">{game.id.slice(0, 4)}…{game.id.slice(-4)}</b></span>
33
+ <span className="nh-sep"></span>
34
+ <span>{t('shell.status.turn')} <b>{game.turn} / {game.max}</b></span>
35
+ <span className="nh-sep"></span>
36
+ <span>{t('shell.status.recon')} <b>{game.recon_remaining} / 3</b></span>
37
+ </div>
38
+ )}
39
+ <LangToggle lang={lang} setLang={setLang} ariaLabel={t('shell.lang.toggle.aria')} />
40
+ </div>
41
  </header>
42
  );
43
  }
44
 
45
+ function LangToggle({
46
+ lang, setLang, ariaLabel,
47
+ }: {
48
+ lang: Language;
49
+ setLang: (l: Language) => void;
50
+ ariaLabel: string;
51
+ }) {
52
+ return (
53
+ <div
54
+ role="group"
55
+ aria-label={ariaLabel}
56
+ className="nh-lang"
57
+ style={{
58
+ display: 'inline-flex',
59
+ gap: 2,
60
+ fontFamily: 'var(--font-mono)',
61
+ fontSize: 11,
62
+ textTransform: 'uppercase',
63
+ letterSpacing: '0.06em',
64
+ }}
65
+ >
66
+ <button
67
+ type="button"
68
+ onClick={() => setLang('en')}
69
+ aria-pressed={lang === 'en'}
70
+ style={{
71
+ background: 'transparent',
72
+ border: 0,
73
+ padding: '4px 6px',
74
+ cursor: 'pointer',
75
+ color: lang === 'en' ? 'var(--ink)' : 'var(--slate)',
76
+ fontWeight: lang === 'en' ? 700 : 400,
77
+ textDecoration: lang === 'en' ? 'underline' : 'none',
78
+ textUnderlineOffset: 3,
79
+ fontFamily: 'inherit',
80
+ fontSize: 'inherit',
81
+ letterSpacing: 'inherit',
82
+ textTransform: 'inherit',
83
+ }}
84
+ >
85
+ EN
86
+ </button>
87
+ <span style={{ color: 'var(--slate)' }}>|</span>
88
+ <button
89
+ type="button"
90
+ onClick={() => setLang('de')}
91
+ aria-pressed={lang === 'de'}
92
+ style={{
93
+ background: 'transparent',
94
+ border: 0,
95
+ padding: '4px 6px',
96
+ cursor: 'pointer',
97
+ color: lang === 'de' ? 'var(--ink)' : 'var(--slate)',
98
+ fontWeight: lang === 'de' ? 700 : 400,
99
+ textDecoration: lang === 'de' ? 'underline' : 'none',
100
+ textUnderlineOffset: 3,
101
+ fontFamily: 'inherit',
102
+ fontSize: 'inherit',
103
+ letterSpacing: 'inherit',
104
+ textTransform: 'inherit',
105
+ }}
106
+ >
107
+ DE
108
+ </button>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ const PHASES: { n: string; key: 'briefing' | 'recon' | 'action' | 'deliberation' | 'adjudication' }[] = [
114
+ { n: '01', key: 'briefing' },
115
+ { n: '02', key: 'recon' },
116
+ { n: '03', key: 'action' },
117
+ { n: '04', key: 'deliberation' },
118
+ { n: '05', key: 'adjudication' },
119
  ];
120
 
121
  export function PhaseRail({ current }: { current: 'briefing' | 'recon' | 'action' | 'deliberation' | 'adjudication' }) {
122
+ const { t } = useI18n();
123
  const order = ['briefing', 'recon', 'action', 'deliberation', 'adjudication'];
124
  const idx = order.indexOf(current);
125
  return (
126
  <div className="nh-rail">
127
  {PHASES.map((p, i) => (
128
  <div key={p.n} className={`nh-rail__p ${i < idx ? 'done' : ''} ${i === idx ? 'active' : ''}`}>
129
+ <span className="n mono">{p.n}</span>
130
+ <span className="t">{t(`phaseRail.${p.key}` as const)}</span>
131
  </div>
132
  ))}
133
  </div>
 
139
  className?: string; fog?: boolean; inverse?: boolean;
140
  }) {
141
  const { tag, title, children, footnote, className = '', fog = false, inverse = false } = props;
142
+ const { t } = useI18n();
143
  return (
144
  <article className={`nh-dossier ${fog ? 'fog' : ''} ${inverse ? 'inverse' : ''} ${className}`}>
145
+ {fog && <span className="nh-dossier__fog">{t('shell.dossier.fog')}</span>}
146
  {tag && <span className="nh-dossier__tag">{tag}</span>}
147
  {title && <h3 className="nh-dossier__h">{title}</h3>}
148
  {children}
frontend/src/i18n/de.ts ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // German translations. Must cover every key in en.ts. Missing keys fall back
2
+ // to English at runtime — the visible English string is the regression signal.
3
+
4
+ import type { StringKey } from './en';
5
+
6
+ export const de: Partial<Record<StringKey, string>> = {
7
+ // ---- Top bar / Shell ----
8
+ "shell.brand.tag": "Gefechtsstand",
9
+ "shell.dossier.fog": "Nebel des Krieges",
10
+ "shell.nav.cases": "Fälle",
11
+ "shell.nav.newCase": "Neuer Fall",
12
+ "shell.status.case": "Fall",
13
+ "shell.status.turn": "Runde",
14
+ "shell.status.recon": "Aufklärung",
15
+ "shell.lang.toggle.aria": "Sprache wechseln",
16
+ "shell.footer.tagline": "Norhard · War — Gefechtsstand",
17
+ "shell.footer.build": "MVP-Frontend",
18
+
19
+ // ---- Phase rail ----
20
+ "phaseRail.briefing": "Briefing",
21
+ "phaseRail.recon": "Aufklärung",
22
+ "phaseRail.action": "Aktion",
23
+ "phaseRail.deliberation": "Beratung",
24
+ "phaseRail.adjudication": "Urteil",
25
+
26
+ // ---- GamesList ----
27
+ "gamesList.eyebrow.lead": "Fälle",
28
+ "gamesList.eyebrow.note": "aktiv & abgeschlossen",
29
+ "gamesList.title": "Jeder durchgespielte Fall, mit Belegen.",
30
+ "gamesList.dek": "Öffnen Sie einen aktiven Fall, um die Beratung fortzusetzen, oder lesen Sie einen abgeschlossenen Debrief mit Urteilskette und Postmortem.",
31
+ "gamesList.docket": "Akten",
32
+ "gamesList.newCase": "+ Neuer Fall",
33
+ "gamesList.loading": "Fälle werden geladen…",
34
+ "gamesList.empty.lead": "Noch keine Fälle.",
35
+ "gamesList.empty.cta": "Neuen Fall öffnen",
36
+ "gamesList.empty.tail": "und Briefing für Runde 1 generieren.",
37
+ "gamesList.col.case": "Fall",
38
+ "gamesList.col.scenario": "Szenario",
39
+ "gamesList.col.turn": "Runde",
40
+ "gamesList.col.status": "Status",
41
+ "gamesList.col.model": "Modell",
42
+ "gamesList.col.updated": "Geändert",
43
+ "gamesList.scenario.summary": "{industry} · vs {rival}",
44
+ "gamesList.status.active": "aktiv",
45
+ "gamesList.status.complete": "abgeschlossen",
46
+
47
+ // ---- NewGame ----
48
+ "newGame.eyebrow.label": "Neuer Fall",
49
+ "newGame.eyebrow.note": "Szenario auswählen",
50
+ "newGame.title": "Wählen Sie ein Dossier. Wir generieren Runde 1 von Grund auf.",
51
+ "newGame.dek": "Jedes Szenario liefert ein Rivalen-Drehbuch, eine Vorstandsagenda, einen Beraterstab und ein Briefing mit Nebel des Krieges. Generierung dauert ca. dreißig Sekunden.",
52
+ "newGame.designOwn.eyebrow": "Oder selbst entwerfen",
53
+ "newGame.designOwn.title": "Neuen Fall mit dem Setup-Agenten aufbauen",
54
+ "newGame.designOwn.dek": "Schildern Sie Ihre Lage; der Agent stellt Rückfragen und erstellt ein neues Szenario — Welt-Kompendium, Beraterstab, verdeckte Agenden.",
55
+ "newGame.designOwn.cta": "Setup-Agent starten →",
56
+ "newGame.scenarios.loading": "Szenarien werden geladen…",
57
+ "newGame.scenario.role": "Rolle",
58
+ "newGame.scenario.agentCount": "{count} Berater am Vorstandstisch.",
59
+ "newGame.scenario.footnote": "Fünf Runden · 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 Briefing · ~30s · Runde 1 von 5.",
66
+ "newGame.ready.modelOnly": "Modell generiert Briefing · ~30s · Runde 1 von 5.",
67
+ "newGame.ready.openCta": "Fall öffnen →",
68
+ "newGame.ready.busy": "Briefing wird generiert…",
69
+ "newGame.lang.eyebrow": "Sprache",
70
+ "newGame.lang.helper": "Briefing, Debrief und Stimmen der Berater folgen dieser Wahl.",
71
+ "newGame.lang.en": "English",
72
+ "newGame.lang.de": "Deutsch",
73
+
74
+ // ---- NewGame · per-role overrides ----
75
+ "newGame.roleOverrides.eyebrow": "Modell-Routing pro Rolle",
76
+ "newGame.roleOverrides.allDefault": "Alle Rollen nutzen den Spiel-Standard",
77
+ "newGame.roleOverrides.someRouted": "{count} Rolle auf anderes Modell umgeleitet",
78
+ "newGame.roleOverrides.someRouted.plural": "{count} Rollen auf andere Modelle umgeleitet",
79
+ "newGame.roleOverrides.hide": "Verbergen ↑",
80
+ "newGame.roleOverrides.configure": "Konfigurieren ↓",
81
+ "newGame.roleOverrides.intro.lead": "Jede Rolle kann an ein bestimmtes Modell gebunden werden — auf Standard belassen, um zu nutzen",
82
+ "newGame.roleOverrides.intro.tail": "Praktisch, um günstige Planung an kleine Modelle zu geben und das Flaggschiff für die Adjudikation zu reservieren.",
83
+ "newGame.roleOverrides.serverDefault": "(Server-Standard)",
84
+ "newGame.roleOverrides.useDefault": "(Spiel-Standard nutzen)",
85
+ "newGame.roleOverrides.reset": "Auf Standard zurücksetzen",
86
+ "newGame.roleOverrides.role.control": "Briefings, Adjudikation, Welt-Updates",
87
+ "newGame.roleOverrides.role.agent": "Beraterstimmen am Tisch",
88
+ "newGame.roleOverrides.role.rival": "Planer der Rivalen-CEO",
89
+ "newGame.roleOverrides.role.critic": "Kritiker-Agent",
90
+ "newGame.roleOverrides.role.premortem": "Pre-Mortem-Agent",
91
+ "newGame.roleOverrides.role.evaluator": "Unabhängiger Bewerter",
92
+
93
+ // ---- GameView · hero ----
94
+ "gameView.eyebrow.execBriefing": "Runde {turn} — Vorstands-Briefing",
95
+ "gameView.eyebrow.case": "Fall",
96
+ "gameView.status.paused": "Pausiert — bereit zum Fortsetzen",
97
+ "gameView.status.action": "Aktionsphase offen",
98
+ "gameView.status.recon": "Aufklärungsphase",
99
+ "gameView.status.actionSubmitted": "Aktion eingereicht — bereit zum Vorrücken",
100
+ "gameView.status.deliberating": "Berater beraten",
101
+ "gameView.status.adjudicating": "Wird juriert",
102
+ "gameView.byline.lead": "Eingereicht von Control · Nebel-Abschnitte unten · Aufklärungsbudget {budget} / 3",
103
+ "gameView.byline.engine": "Engine",
104
+ "gameView.errorTitle": "Fall konnte nicht geladen werden",
105
+ "gameView.errorTag": "Fehler",
106
+ "gameView.retry": "Erneut versuchen",
107
+ "gameView.loading": "Briefing wird geladen…",
108
+ "gameView.fog.label": "Nebel des Krieges · unbestätigt",
109
+
110
+ // ---- GameView · sections ----
111
+ "gameView.section.tag": "Abschnitt · {heading}",
112
+ "gameView.decisions.tag": "Entscheidungspunkte · Runde {turn}",
113
+ "gameView.decisions.title": "{count} Züge stehen zur Wahl. Wählen Sie einen, um zu komponieren.",
114
+ "gameView.decisions.compose": "Komponieren →",
115
+
116
+ // ---- GameView · recon panel ----
117
+ "gameView.recon.tag": "Aufklärungs-Queue",
118
+ "gameView.recon.title": "Budget {budget} von 3 in dieser Runde.",
119
+ "gameView.recon.empty": "Noch keine Anfragen verbraucht. Jede Anfrage offenbart das Thema dem Rivalen in zwei Runden.",
120
+ "gameView.recon.resolved": "Gelöst · {who}",
121
+ "gameView.recon.reveals": " · wird dem Rivalen offenbart",
122
+ "gameView.recon.askLabel": "Anderen Berater befragen",
123
+ "gameView.recon.placeholder": "Eine knappe Frage…",
124
+ "gameView.recon.cost": "Jede Anfrage kostet 1 / 3.",
125
+ "gameView.recon.spend": "Anfrage senden →",
126
+ "gameView.recon.asking": "Anfrage läuft…",
127
+ "gameView.recon.exhausted": "Aufklärungsbudget aufgebraucht. Aktion einreichen und vorrücken.",
128
+
129
+ // ---- GameView · agents panel ----
130
+ "gameView.agents.tag": "Beraterstab",
131
+ "gameView.agents.title": "Stimmen am Tisch",
132
+ "gameView.agents.kind.advisor": "Berater",
133
+ "gameView.agents.kind.rival": "Rivale",
134
+
135
+ // ---- GameView · composer ----
136
+ "gameView.composer.title": "Ihr Zug, Runde {turn}",
137
+ "gameView.composer.submitted": "Eingereicht",
138
+ "gameView.composer.tab.coach": "Mit Coach sprechen",
139
+ "gameView.composer.tab.manual": "Manuell",
140
+ "gameView.composer.afterSubmit": "Aktion für Runde {turn} eingereicht. Vorrücken, sobald Sie bereit sind — das Modell schreibt Urteile gegen Ihren Beraterstab und das Rivalen-Drehbuch. Dauert 2–5 Minuten.",
141
+ "gameView.composer.advanceWarning": "Vorrücken ist unwiderruflich. Stellen Sie sicher, dass Ihre Anker vollständig sind.",
142
+ "gameView.composer.resume": "Runde fortsetzen →",
143
+ "gameView.composer.resuming": "Wird fortgesetzt…",
144
+ "gameView.composer.advance": "Runde vorrücken →",
145
+ "gameView.composer.resolving": "Runde wird aufgelöst…",
146
+ "gameView.composer.pause": "Pause",
147
+ "gameView.composer.pausing": "Pausiert…",
148
+ "gameView.composer.pause.title": "Beim nächsten Phasen-Checkpoint anhalten",
149
+
150
+ // ---- GameView · manual form ----
151
+ "gameView.form.intent.label": "Absicht",
152
+ "gameView.form.intent.placeholder": "z. B. Helvetia bei 14 % effektiv binden, bevor Aergis ein LoI unterzeichnet.",
153
+ "gameView.form.why.label": "Warum jetzt",
154
+ "gameView.form.why.placeholder": "Die strategische Begründung. Was ändert sich, wenn Sie handeln statt eine Runde zu warten.",
155
+ "gameView.form.charCount": "{count} / {max}",
156
+ "gameView.form.anchors.legend": "Anker · jede Aktion muss drei zitieren",
157
+ "gameView.form.anchors.count": "{filled} / 3",
158
+ "gameView.form.anchors.add": "+ Vierten Anker hinzufügen (optional)",
159
+ "gameView.form.anchors.remove": "entfernen",
160
+ "gameView.form.anchors.priorAnalogue": "Frühere Analogie",
161
+ "gameView.form.anchors.statedCapability": "Erklärte Fähigkeit",
162
+ "gameView.form.anchors.resourceCommitted": "Verbindlich zugesagte Ressource",
163
+ "gameView.form.anchors.hint.priorAnalogue": "Ein früherer Fall — Ihrer oder eines anderen — bei dem dies funktioniert hat.",
164
+ "gameView.form.anchors.hint.statedCapability": "Eine Fähigkeit, die Sie bereits dokumentiert haben.",
165
+ "gameView.form.anchors.hint.resourceCommitted": "Konkretes Geld, Personal oder Zeit, das Sie hinterlegen.",
166
+ "gameView.form.anchors.placeholder.priorAnalogue": "z. B. Helvetia-Verlängerung 2022 — 16 % effektiv, 3-Jahres-Auto-Renewal",
167
+ "gameView.form.anchors.placeholder.statedCapability": "z. B. Horizont-X COP 3,4 nach UNI 11300 validiert (Juni 2024)",
168
+ "gameView.form.anchors.placeholder.resourceCommitted": "z. B. €500k/Quartal Pönalebudget, von Lange unterzeichnet",
169
+ "gameView.form.submit.violationsBlock": "Verstöße oben beheben, um die Einreichung freizuschalten.",
170
+ "gameView.form.submit.preflight": "Wird gegen harte Bedingungen geprüft…",
171
+ "gameView.form.submit.helper": "Einreichen sperrt Ihren Zug für Runde {turn}. Danach vorrücken.",
172
+ "gameView.form.submit.cta": "Aktion einreichen →",
173
+ "gameView.form.submit.checking": "Prüfung läuft…",
174
+ "gameView.form.submit.submitting": "Wird eingereicht…",
175
+
176
+ // ---- GameView · hard constraints ----
177
+ "gameView.constraints.eyebrow": "Harte Bedingungen",
178
+ "gameView.constraints.empty": "Keine Bedingungen für dieses Spiel deklariert",
179
+ "gameView.constraints.summary": "{hard} hart · bei Einreichung erzwungen",
180
+ "gameView.constraints.summaryWithSoft": "{hard} hart · {soft} weich · bei Einreichung erzwungen",
181
+ "gameView.constraints.show": "Anzeigen ↓",
182
+ "gameView.constraints.hide": "Verbergen ↑",
183
+ "gameView.constraints.emptyHint": "Keine harten Bedingungen aktiv. Fügen Sie unten eine hinzu, oder laden Sie beim Setup Quelldokumente hoch und lassen Sie den Setup-Agenten sie automatisch extrahieren.",
184
+ "gameView.constraints.add.cta": "+ Bedingung hinzufügen",
185
+ "gameView.constraints.footnote": "Harte Bedingungen blockieren bei Einreichung jede Aktion, die sie verletzt.",
186
+ "gameView.constraints.draft.description": "Beschreibung",
187
+ "gameView.constraints.draft.descriptionPlaceholder": "z. B. Der Helvetia-MSA verbietet Preiserhöhungen über 4 % p. a. ohne 90-Tage-Frist.",
188
+ "gameView.constraints.draft.rationale": "Begründung",
189
+ "gameView.constraints.draft.rationalePlaceholder": "z. B. Bruch löst eine Pönale-Klausel von €12 Mio. und ein öffentliches Schiedsverfahren aus.",
190
+ "gameView.constraints.draft.testQuestion": "Prüffrage",
191
+ "gameView.constraints.draft.testQuestionPlaceholder": "z. B. \"Erhöht diese Aktion die Preise im Helvetia-Vertrag um mehr als 4 % ohne 90-Tage-Frist?\"",
192
+ "gameView.constraints.draft.severity": "Schweregrad",
193
+ "gameView.constraints.draft.severity.hard": "Hart - blockiert jede verletzende Aktion",
194
+ "gameView.constraints.draft.severity.soft": "Weich - markieren, aber zulassen",
195
+ "gameView.constraints.draft.cancel": "Abbrechen",
196
+ "gameView.constraints.draft.add": "Bedingung hinzufügen →",
197
+ "gameView.constraints.draft.adding": "Wird hinzugefügt…",
198
+ "gameView.constraints.row.removeTitle": "Bedingung entfernen",
199
+ "gameView.constraints.row.remove": "entfernen",
200
+ "gameView.constraints.row.expand": "Klicken zum Ausklappen",
201
+ "gameView.constraints.row.collapse": "Klicken zum Einklappen",
202
+ "gameView.constraints.row.rationale": "Begründung",
203
+ "gameView.constraints.row.testQuestion": "Prüffrage",
204
+ "gameView.constraints.row.sourceRef": "Quellverweis",
205
+ "gameView.constraints.delete.confirm.scenario": "Diese Bedingung stammt aus der Szenariodatei.\n\nDas Entfernen umgeht eine ausdrücklich autorisierte Wissensbasis. Fortfahren?",
206
+ "gameView.constraints.delete.confirm.document": "Diese Bedingung stammt aus einem hochgeladenen Dokument.\n\nDas Entfernen umgeht eine ausdrücklich autorisierte Wissensbasis. Fortfahren?",
207
+ "gameView.violations.eyebrow": "Verstoß gegen harte Bedingung · Aktion blockiert",
208
+ "gameView.violations.dismiss": "schließen",
209
+ "gameView.violations.tail": "Aktion oben überarbeiten — der nächste Versuch wird neu geprüft.",
210
+ "gameView.violations.coachBlocked": "Aktion durch harte Bedingungen blockiert — siehe Panel über dem Composer.",
211
+
212
+ // ---- GameView · coach ----
213
+ "gameView.coach.intro": "Schildern Sie Ihren Zug in normaler Prosa. Der Coach entwirft eine strukturierte Aktion mit drei Ankern — der Matrix-Game-Schiedsrichter bewertet die Plausibilität daran, also zählt Spezifität. Bei einem unklaren Anker stellt der Coach eine fokussierte Rückfrage. Maximal zwei Runden.",
214
+ "gameView.coach.placeholder": "z. B. Aergis zuvorkommen und Helvetia bei 14 % effektiv binden, bevor sie das LoI unterzeichnen. Anker auf der Helvetia-Verlängerung 2022 — wir haben die Horizont-X UNI-11300-Validierung und ein €500k/Quartal-Pönalebudget, von Lange unterzeichnet.",
215
+ "gameView.coach.charHint": "{count} / 4000 · Strg/Cmd+Enter zum Senden",
216
+ "gameView.coach.start": "Coach starten →",
217
+ "gameView.coach.starting": "Entwurf läuft…",
218
+ "gameView.coach.replyPlaceholder": "Ihre Antwort… (Strg/Cmd+Enter zum Senden)",
219
+ "gameView.coach.replyHint": "{count} / 4000 · Runde {round} von 2",
220
+ "gameView.coach.send": "Senden →",
221
+ "gameView.coach.sending": "Wird gesendet…",
222
+ "gameView.coach.busy.label": "Coach",
223
+ "gameView.coach.busy.suffix": "entwirft…",
224
+ "gameView.coach.draft.eyebrowReady": "Aktionsentwurf · bereit zur Einreichung",
225
+ "gameView.coach.draft.eyebrowProgress": "Aktionsentwurf · in Arbeit ({rounds}/2 Runden)",
226
+ "gameView.coach.draft.footnote": "Einreichen sperrt diesen Zug für Runde {turn}.",
227
+ "gameView.coach.approve.eyebrow": "Zur Einreichung freigeben",
228
+ "gameView.coach.approve.title": "Entwurf als Aktion für Runde {turn} festschreiben",
229
+ "gameView.coach.approve.editManual": "Manuell bearbeiten →",
230
+ "gameView.coach.approve.restart": "Neu starten",
231
+ "gameView.coach.approve.submit": "Aktion einreichen →",
232
+ "gameView.coach.approve.submitting": "Wird eingereicht…",
233
+ "gameView.coach.restart.cta": "Gespräch neu starten",
234
+ "gameView.coach.draft.intent": "Absicht",
235
+ "gameView.coach.draft.why": "Warum jetzt",
236
+ "gameView.coach.draft.anchorsLabel": "Anker · {count} von 3",
237
+ "gameView.coach.label.user": "Sie",
238
+ "gameView.coach.label.coach": "Coach",
239
+
240
+ // ---- GameView · verdict history ----
241
+ "gameView.verdicts.title": "Frühere Runden · das Urteil des Schiedsrichters",
242
+ "gameView.verdicts.turn": "Runde {turn}",
243
+ "gameView.verdicts.resolved": "{count} Urteile aufgelöst",
244
+ "gameView.verdicts.actionOnly": "Aktion eingereicht",
245
+ "gameView.verdicts.fork": "Aus Runde {turn} verzweigen →",
246
+ "gameView.verdicts.forking": "Wird verzweigt…",
247
+ "gameView.verdicts.fork.title": "Neuen Fall ab Runde {turn} verzweigen und vorwärts spielen",
248
+ "gameView.verdicts.playerAction": "Spieleraktion",
249
+
250
+ // ---- GameView · orchestrator ----
251
+ "gameView.orch.eyebrow": "Szenario-Orchestrator · Vor-Spiel-Landschaft",
252
+ "gameView.orch.title.complete": "Sehen Sie, wie drei CEO-Profile diese Runde spielen würden.",
253
+ "gameView.orch.title.running": "Drei Profile werden parallel simuliert.",
254
+ "gameView.orch.title.idle": "Bevor Sie sich festlegen, sehen Sie, wie drei Profile diese Runde spielen.",
255
+ "gameView.orch.show": "Anzeigen ↓",
256
+ "gameView.orch.hide": "Verbergen ↑",
257
+ "gameView.orch.idleDek": "Drei Strategievektoren spielen eine Proxy-Version dieser Runde bei verschiedenen Spielstärken. Sie erhalten einen Vergleich der Ergebnisse, bevor Sie Ihren eigenen Zug festlegen. Dauert 3–5 Minuten.",
258
+ "gameView.orch.run": "Orchestrator starten →",
259
+ "gameView.orch.locked": "Orchestrator läuft nur vor der Aktion — aus Runde 1 verzweigen, um neu zu starten.",
260
+ "gameView.orch.progress": "{done} / {total} Zweige fertig",
261
+ "gameView.orch.progressDek": "Läuft im Hintergrund. Sie können das Briefing lesen — diese Seite aktualisiert sich live.",
262
+ "gameView.orch.vector.complete": "✓ fertig",
263
+ "gameView.orch.vector.running": "⋯ läuft",
264
+ "gameView.orch.generatingVectors": "Strategievektoren werden generiert…",
265
+ "gameView.orch.failed": "Orchestrierung fehlgeschlagen: {error}",
266
+ "gameView.orch.failed.unknown": "unbekannter Fehler",
267
+ "gameView.orch.retry": "Erneut versuchen →",
268
+ "gameView.orch.rerun": "Orchestrator neu starten →",
269
+ "gameView.orch.skipped.eyebrow": "Abgelehnt",
270
+ "gameView.orch.skipped.fallback": "Anker-Vorprüfung fehlgeschlagen.",
271
+ "gameView.orch.played": "Gespielt als",
272
+ "gameView.orch.adopt": "Diesen Auftakt übernehmen →",
273
+ "gameView.orch.fullSynthesis": "Vollständige Synthese · {chars} Zeichen",
274
+
275
+ // ---- LiveProgress ----
276
+ "liveProgress.eyebrow": "Live · Runde im Lauf",
277
+ "liveProgress.titleDefault": "Beratungen und Urteile in Echtzeit verfolgen.",
278
+ "liveProgress.titlePaused": "Pausiert — {label}",
279
+ "liveProgress.cost.aria": "kumulierte Kosten",
280
+ "liveProgress.cost.suffix": "verbraucht",
281
+ "liveProgress.paused.eyebrow": "Pausiert",
282
+ "liveProgress.paused.body": "Pausiert am Ende von {label}. Fortsetzen, um weiterzumachen.",
283
+ "liveProgress.streamErr.disconnected": "Live-Stream getrennt — Seite neu laden, um neu zu verbinden.",
284
+ "liveProgress.engineErr.fallback": "Engine meldete einen Fehler.",
285
+ "liveProgress.steps.deliberation": "Beratung",
286
+ "liveProgress.steps.critic": "Kritiker",
287
+ "liveProgress.steps.premortem": "Pre-Mortem",
288
+ "liveProgress.steps.adjudication": "Urteil",
289
+ "liveProgress.steps.evaluator": "Bewerter",
290
+ "liveProgress.steps.worldUpdate": "Welt-Update",
291
+ "liveProgress.steps.nextBriefing": "Nächstes Briefing",
292
+ "liveProgress.rail.aria": "Phasenfortschritt",
293
+ "liveProgress.deliberations": "Beratungen",
294
+ "liveProgress.deliberations.thinking": "Berater denken nach…",
295
+ "liveProgress.deliberations.empty": "Noch keine Beratungen.",
296
+ "liveProgress.deliberations.expects": "erwartet",
297
+ "liveProgress.deliberations.fears": "befürchtet",
298
+ "liveProgress.verdicts": "Urteile",
299
+ "liveProgress.verdicts.empty": "Noch keine Urteile in dieser Runde.",
300
+ "liveProgress.verdict.plausibility": "P{n}",
301
+ "liveProgress.verdict.outcome.major": "Wirkung · stark",
302
+ "liveProgress.verdict.outcome.minor": "Wirkung · gering",
303
+ "liveProgress.verdict.outcome.noEffect": "keine Wirkung",
304
+ "liveProgress.verdict.outcome.backfires": "geht nach hinten los",
305
+
306
+ // ---- GameView · advance log ----
307
+ "gameView.advance.seed": "[{time}] Orchestrator: Beratungen für Runde {turn} werden gesetzt…",
308
+ "gameView.advance.resolved": "[{time}] ✓ Runde {turn} aufgelöst",
309
+
310
+ // ---- SetupCase ----
311
+ "setup.eyebrow.lead": "Neuer Fall",
312
+ "setup.eyebrow.tail": "mit dem Setup-Agenten entwerfen",
313
+ "setup.intro.title": "Schildern Sie die Lage. Der Agent stellt Rückfragen und baut dann das Szenario auf.",
314
+ "setup.intro.dek": "Schreiben Sie ein bis zwei Absätze über das Unternehmen, den Rivalen oder die Bedrohung, die Risiken und alle Bedingungen, die Sie schon kennen. Der Setup-Agent füllt die Lücken: Beraterstab, Welt-Kompendium, Startmetriken, verdeckte Agenden. Dauert ein paar Q&A-Runden (30–60s pro Runde).",
315
+ "setup.caseId.label": "Fall-ID",
316
+ "setup.caseId.charCount": "{count} / 60",
317
+ "setup.caseId.placeholder": "z. B. nord_heatpump_takeover",
318
+ "setup.caseId.helper": "Kleinbuchstaben, Ziffern, Unterstriche. Muss mit einem Buchstaben beginnen. 3–60 Zeichen.",
319
+ "setup.caseId.invalid": "Ungültiges Format.",
320
+ "setup.brief.label": "Lagebrief",
321
+ "setup.brief.charCount": "{count} / 2000 (mind. 10)",
322
+ "setup.brief.placeholder": "z. B. Wir sind ein mittelgroßer deutscher Wärmepumpen-Hersteller, €680 Mio. Umsatz, 2800 Mitarbeitende. Ein chinesischer Anbieter (Aergis Climate) hat eine italienische Tochter erworben und unterbietet uns im EU-Gewerbesegment. Der Vorstand erwartet eine Antwort für Q2. Regulatorischer Druck durch den F-Gas-Ausstieg spielt uns auf dem Papier in die Hände, aber der Betriebsrat ist nervös wegen Restrukturierung.",
323
+ "setup.start.helper": "Der Agent nutzt dies als Ausgangskontext.",
324
+ "setup.start.cta": "Gespräch starten →",
325
+ "setup.start.busy": "Gespräch wird gestartet…",
326
+ "setup.chat.eyebrow.lead": "Setup-Agent",
327
+ "setup.chat.eyebrow.case": "Fall",
328
+ "setup.chat.status.gathering": "Kontext wird erhoben",
329
+ "setup.chat.status.ready": "Brief bereit — unten prüfen",
330
+ "setup.chat.title.gathering": "Beantworten Sie die Fragen des Agenten. Er stellt noch ein paar weitere und übergibt Ihnen dann einen synthetisierten Brief.",
331
+ "setup.chat.title.ready": "Der Agent hat einen vollständigen Brief. Prüfen und festschreiben, um das Szenario aufzubauen.",
332
+ "setup.chat.thinking": "denkt nach…",
333
+ "setup.chat.label.user": "Sie",
334
+ "setup.chat.label.agent": "Setup-Agent",
335
+ "setup.synthesis.eyebrow": "Synthetisierter Brief · genau das wird aufgebaut",
336
+ "setup.synthesis.footnote.lead": "Beim Festschreiben wird",
337
+ "setup.synthesis.footnote.tail": "geschrieben — world_bible, agents.yaml, initial_state.json — dann öffnet sich Runde 1.",
338
+ "setup.commit.eyebrow": "Zum Aufbau freigeben",
339
+ "setup.commit.title": "Brief festschreiben & Runde 1 starten",
340
+ "setup.commit.cta": "Festschreiben & starten →",
341
+ "setup.committing.lead": "Aufbau von",
342
+ "setup.committing.tail": "und Runde 1 wird generiert…",
343
+ "setup.committing.helper": "Dauert ~30–60s (Aufbau + Briefing). Nicht weg navigieren.",
344
+ "setup.reply.placeholder": "Ihre Antwort… (Strg/Cmd+Enter zum Senden)",
345
+ "setup.reply.charCount": "{count} / 4000",
346
+ "setup.reply.send": "Senden →",
347
+ "setup.reply.sending": "Wird gesendet…",
348
+ "setup.docs.eyebrow": "Quelldokumente",
349
+ "setup.docs.title": "Quellmaterial einlegen — der Setup-Agent extrahiert daraus beim Festschreiben harte Bedingungen.",
350
+ "setup.docs.count": "{used} / {max}",
351
+ "setup.docs.uploading": "Hochladen…",
352
+ "setup.docs.limitReached": "Limit erreicht — Dokument entfernen, um ein weiteres hochzuladen.",
353
+ "setup.docs.dropLead": "Dokumente hier ablegen",
354
+ "setup.docs.dropTail": "oder klicken zum Auswählen —",
355
+ "setup.docs.dropMeta": "max. 25 MB pro Datei.",
356
+ "setup.docs.remove": "entfernen",
357
+ "setup.docs.cap": "Maximal {max} Dokumente pro Sitzung — derzeit {count} hochgeladen.",
358
+ "setup.docs.unsupported": "\"{file}\": nur {exts} werden akzeptiert.",
359
+ "setup.docs.tooLarge": "\"{file}\": überschreitet das 25-MB-Limit ({size}).",
360
+ "setup.docs.uploadFail": "\"{file}\": {message}",
361
+ "setup.docs.removeFail": "\"{file}\" konnte nicht entfernt werden: {message}",
362
+
363
+ // ---- Debrief ----
364
+ "debrief.eyebrow.label": "Debrief",
365
+ "debrief.eyebrow.case": "Fall {short}…",
366
+ "debrief.eyebrow.tail": "Postmortem",
367
+ "debrief.title": "Das Urteil des Vorstands, aktenkundig.",
368
+ "debrief.dek": "Fünf Runden Beratung, 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…",
372
+ "debrief.dossier.tag": "Debrief · GET /games/{id}/debrief",
373
+ };
frontend/src/i18n/en.ts ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // English strings — the source of truth. Every key here must also exist in de.ts.
2
+ // Keys are dotted, scoped by screen / component, lowerCamelCase leaf names.
3
+
4
+ export const en = {
5
+ // ---- Top bar / Shell ----
6
+ "shell.brand.tag": "Board Console",
7
+ "shell.dossier.fog": "Fog of war",
8
+ "shell.nav.cases": "Cases",
9
+ "shell.nav.newCase": "New case",
10
+ "shell.status.case": "Case",
11
+ "shell.status.turn": "Turn",
12
+ "shell.status.recon": "Recon",
13
+ "shell.lang.toggle.aria": "Switch language",
14
+ "shell.footer.tagline": "Norhard · War — Board Console",
15
+ "shell.footer.build": "MVP frontend",
16
+
17
+ // ---- Phase rail (engine phase numbers) ----
18
+ "phaseRail.briefing": "Briefing",
19
+ "phaseRail.recon": "Recon",
20
+ "phaseRail.action": "Action",
21
+ "phaseRail.deliberation": "Deliberation",
22
+ "phaseRail.adjudication": "Adjudication",
23
+
24
+ // ---- GamesList ----
25
+ "gamesList.eyebrow.lead": "Cases",
26
+ "gamesList.eyebrow.note": "active & closed engagements",
27
+ "gamesList.title": "Every case you've war-gamed, with the receipts.",
28
+ "gamesList.dek": "Open a live case to continue deliberation, or review a closed debrief for its verdict chain and post-mortem.",
29
+ "gamesList.docket": "Docket",
30
+ "gamesList.newCase": "+ New case",
31
+ "gamesList.loading": "Loading cases…",
32
+ "gamesList.empty.lead": "No cases yet.",
33
+ "gamesList.empty.cta": "Open a new case",
34
+ "gamesList.empty.tail": "to generate a Turn 1 briefing.",
35
+ "gamesList.col.case": "Case",
36
+ "gamesList.col.scenario": "Scenario",
37
+ "gamesList.col.turn": "Turn",
38
+ "gamesList.col.status": "Status",
39
+ "gamesList.col.model": "Model",
40
+ "gamesList.col.updated": "Updated",
41
+ "gamesList.scenario.summary": "{industry} · vs {rival}",
42
+ "gamesList.status.active": "active",
43
+ "gamesList.status.complete": "complete",
44
+
45
+ // ---- NewGame ----
46
+ "newGame.eyebrow.label": "New case",
47
+ "newGame.eyebrow.note": "choose the scenario",
48
+ "newGame.title": "Select a dossier. We'll generate Turn 1 from the ground up.",
49
+ "newGame.dek": "Each scenario seeds a rival playbook, board agenda, agent roster, and fog-of-war intelligence briefing. Generation takes about thirty seconds.",
50
+ "newGame.designOwn.eyebrow": "Or design your own",
51
+ "newGame.designOwn.title": "Build a new case with the setup agent",
52
+ "newGame.designOwn.dek": "Describe your situation; the agent asks clarifying questions and scaffolds a fresh scenario — world bible, agent roster, hidden agendas.",
53
+ "newGame.designOwn.cta": "Talk to the setup agent →",
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": "Five turns · 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 briefing · ~30s · Turn 1 of 5.",
64
+ "newGame.ready.modelOnly": "Model generates briefing · ~30s · Turn 1 of 5.",
65
+ "newGame.ready.openCta": "Open case →",
66
+ "newGame.ready.busy": "Generating briefing…",
67
+ "newGame.lang.eyebrow": "Language",
68
+ "newGame.lang.helper": "The briefing, debrief, and agent voices follow this choice.",
69
+ "newGame.lang.en": "English",
70
+ "newGame.lang.de": "Deutsch",
71
+
72
+ // ---- NewGame · per-role overrides ----
73
+ "newGame.roleOverrides.eyebrow": "Per-role model routing",
74
+ "newGame.roleOverrides.allDefault": "All roles use the game default",
75
+ "newGame.roleOverrides.someRouted": "{count} role routed to a different model",
76
+ "newGame.roleOverrides.someRouted.plural": "{count} roles routed to a different model",
77
+ "newGame.roleOverrides.hide": "Hide ↑",
78
+ "newGame.roleOverrides.configure": "Configure ↓",
79
+ "newGame.roleOverrides.intro.lead": "Each role can be pinned to a specific model — leave on the default to use",
80
+ "newGame.roleOverrides.intro.tail": "Useful for routing cheap planning to small models and reserving the flagship for adjudication.",
81
+ "newGame.roleOverrides.serverDefault": "(server default)",
82
+ "newGame.roleOverrides.useDefault": "(use game default)",
83
+ "newGame.roleOverrides.reset": "Reset to defaults",
84
+ "newGame.roleOverrides.role.control": "Briefings, adjudication, world updates",
85
+ "newGame.roleOverrides.role.agent": "Advisory voices in the room",
86
+ "newGame.roleOverrides.role.rival": "Rival CEO planner",
87
+ "newGame.roleOverrides.role.critic": "Critic agent",
88
+ "newGame.roleOverrides.role.premortem": "Pre-mortem agent",
89
+ "newGame.roleOverrides.role.evaluator": "Independent rubric scorer",
90
+
91
+ // ---- GameView · hero ----
92
+ "gameView.eyebrow.execBriefing": "Turn {turn} — Exec Briefing",
93
+ "gameView.eyebrow.case": "Case",
94
+ "gameView.status.paused": "Paused — ready to resume",
95
+ "gameView.status.action": "Action phase open",
96
+ "gameView.status.recon": "Recon phase",
97
+ "gameView.status.actionSubmitted": "Action submitted — ready to advance",
98
+ "gameView.status.deliberating": "Agents deliberating",
99
+ "gameView.status.adjudicating": "Adjudicating",
100
+ "gameView.byline.lead": "Filed by Control · fog-of-war sections below · recon budget {budget} / 3",
101
+ "gameView.byline.engine": "engine",
102
+ "gameView.errorTitle": "Case could not be loaded",
103
+ "gameView.errorTag": "Error",
104
+ "gameView.retry": "Retry",
105
+ "gameView.loading": "Loading briefing…",
106
+ "gameView.fog.label": "Fog of war · unconfirmed",
107
+
108
+ // ---- GameView · sections ----
109
+ "gameView.section.tag": "Section · {heading}",
110
+ "gameView.decisions.tag": "Decision points · Turn {turn}",
111
+ "gameView.decisions.title": "{count} moves are on the table. Pick one to compose.",
112
+ "gameView.decisions.compose": "Compose →",
113
+
114
+ // ---- GameView · recon panel ----
115
+ "gameView.recon.tag": "Recon queue",
116
+ "gameView.recon.title": "Budget {budget} of 3 this turn.",
117
+ "gameView.recon.empty": "No queries spent yet this turn. Each query reveals the topic to the rival two turns from now.",
118
+ "gameView.recon.resolved": "Resolved · {who}",
119
+ "gameView.recon.reveals": " · reveals to rival",
120
+ "gameView.recon.askLabel": "Ask another agent",
121
+ "gameView.recon.placeholder": "One concise question…",
122
+ "gameView.recon.cost": "Each query costs 1 / 3.",
123
+ "gameView.recon.spend": "Spend query →",
124
+ "gameView.recon.asking": "Asking…",
125
+ "gameView.recon.exhausted": "Recon budget exhausted. Submit action and advance.",
126
+
127
+ // ---- GameView · agents panel ----
128
+ "gameView.agents.tag": "Agent roster",
129
+ "gameView.agents.title": "Voices in the room",
130
+ "gameView.agents.kind.advisor": "advisor",
131
+ "gameView.agents.kind.rival": "rival",
132
+
133
+ // ---- GameView · composer ----
134
+ "gameView.composer.title": "Your move, Turn {turn}",
135
+ "gameView.composer.submitted": "Submitted",
136
+ "gameView.composer.tab.coach": "Talk to coach",
137
+ "gameView.composer.tab.manual": "Manual",
138
+ "gameView.composer.afterSubmit": "Action submitted for Turn {turn}. Advance when you're ready — the model writes verdicts against your agent roster and the rival playbook. This takes 2–5 minutes.",
139
+ "gameView.composer.advanceWarning": "Advancing is irreversible. Make sure your anchors are complete.",
140
+ "gameView.composer.resume": "Resume turn →",
141
+ "gameView.composer.resuming": "Resuming…",
142
+ "gameView.composer.advance": "Advance turn →",
143
+ "gameView.composer.resolving": "Resolving turn…",
144
+ "gameView.composer.pause": "Pause",
145
+ "gameView.composer.pausing": "Pausing…",
146
+ "gameView.composer.pause.title": "Stop at the next phase checkpoint",
147
+
148
+ // ---- GameView · manual form ----
149
+ "gameView.form.intent.label": "Intent",
150
+ "gameView.form.intent.placeholder": "e.g. Lock Helvetia at 14% effective before Aergis signs an LoI.",
151
+ "gameView.form.why.label": "Why now",
152
+ "gameView.form.why.placeholder": "The strategic reasoning. What changes if you act vs. wait one turn.",
153
+ "gameView.form.charCount": "{count} / {max}",
154
+ "gameView.form.anchors.legend": "Anchors · each action must cite three",
155
+ "gameView.form.anchors.count": "{filled} / 3",
156
+ "gameView.form.anchors.add": "+ Add a fourth anchor (optional)",
157
+ "gameView.form.anchors.remove": "remove",
158
+ "gameView.form.anchors.priorAnalogue": "Prior analogue",
159
+ "gameView.form.anchors.statedCapability": "Stated capability",
160
+ "gameView.form.anchors.resourceCommitted": "Resource committed",
161
+ "gameView.form.anchors.hint.priorAnalogue": "A past case — yours or someone else's — where this worked.",
162
+ "gameView.form.anchors.hint.statedCapability": "A capability you already have, on the record.",
163
+ "gameView.form.anchors.hint.resourceCommitted": "Specific money, headcount, or time you're putting behind this.",
164
+ "gameView.form.anchors.placeholder.priorAnalogue": "e.g. Helvetia renewal 2022 — 16% effective, 3-year auto-renew",
165
+ "gameView.form.anchors.placeholder.statedCapability": "e.g. Horizont-X COP 3.4 validated under UNI 11300 (Jun 2024)",
166
+ "gameView.form.anchors.placeholder.resourceCommitted": "e.g. €500k/quarter penalty cap, signed by Lange",
167
+ "gameView.form.submit.violationsBlock": "Resolve the constraint violations above to unlock submit.",
168
+ "gameView.form.submit.preflight": "Checking against hard constraints…",
169
+ "gameView.form.submit.helper": "Submitting locks your move for Turn {turn}. Advance after submission.",
170
+ "gameView.form.submit.cta": "Submit action →",
171
+ "gameView.form.submit.checking": "Checking…",
172
+ "gameView.form.submit.submitting": "Submitting…",
173
+
174
+ // ---- GameView · hard constraints ----
175
+ "gameView.constraints.eyebrow": "Hard constraints",
176
+ "gameView.constraints.empty": "No constraints declared on this game",
177
+ "gameView.constraints.summary": "{hard} hard · enforced at submit time",
178
+ "gameView.constraints.summaryWithSoft": "{hard} hard · {soft} soft · enforced at submit time",
179
+ "gameView.constraints.show": "Show ↓",
180
+ "gameView.constraints.hide": "Hide ↑",
181
+ "gameView.constraints.emptyHint": "No hard constraints are active. Add one below, or upload grounding documents at setup time and let the setup agent extract them automatically.",
182
+ "gameView.constraints.add.cta": "+ Add constraint",
183
+ "gameView.constraints.footnote": "Hard constraints block any action that breaches them at submission time.",
184
+ "gameView.constraints.draft.description": "Description",
185
+ "gameView.constraints.draft.descriptionPlaceholder": "e.g. The Helvetia MSA forbids price increases above 4% YoY without 90-day notice.",
186
+ "gameView.constraints.draft.rationale": "Rationale",
187
+ "gameView.constraints.draft.rationalePlaceholder": "e.g. Breach triggers a €12M penalty clause and a public arbitration filing.",
188
+ "gameView.constraints.draft.testQuestion": "Test question",
189
+ "gameView.constraints.draft.testQuestionPlaceholder": "e.g. \"Does this action raise prices on the Helvetia contract by more than 4% without 90-day notice?\"",
190
+ "gameView.constraints.draft.severity": "Severity",
191
+ "gameView.constraints.draft.severity.hard": "Hard - block any action that breaches",
192
+ "gameView.constraints.draft.severity.soft": "Soft - flag but allow",
193
+ "gameView.constraints.draft.cancel": "Cancel",
194
+ "gameView.constraints.draft.add": "Add constraint →",
195
+ "gameView.constraints.draft.adding": "Adding…",
196
+ "gameView.constraints.row.removeTitle": "Remove constraint",
197
+ "gameView.constraints.row.remove": "remove",
198
+ "gameView.constraints.row.expand": "Click to expand",
199
+ "gameView.constraints.row.collapse": "Click to collapse",
200
+ "gameView.constraints.row.rationale": "Rationale",
201
+ "gameView.constraints.row.testQuestion": "Test question",
202
+ "gameView.constraints.row.sourceRef": "Source ref",
203
+ "gameView.constraints.delete.confirm.scenario": "This constraint came from the scenario file.\n\nRemoving it bypasses an explicitly authored knowledge-base intent. Continue?",
204
+ "gameView.constraints.delete.confirm.document": "This constraint came from an uploaded document.\n\nRemoving it bypasses an explicitly authored knowledge-base intent. Continue?",
205
+ "gameView.violations.eyebrow": "Hard constraint violation · action blocked",
206
+ "gameView.violations.dismiss": "dismiss",
207
+ "gameView.violations.tail": "Revise the action above and the next submit attempt will be re-checked.",
208
+ "gameView.violations.coachBlocked": "Action blocked by hard constraints — see panel above the composer.",
209
+
210
+ // ---- GameView · coach ----
211
+ "gameView.coach.intro": "Describe your move in plain prose. The coach will draft a structured action with three anchors — the matrix-game umpire scores plausibility against those anchors, so specificity matters. If a critical anchor is ambiguous, the coach will ask one focused question. Capped at two rounds.",
212
+ "gameView.coach.placeholder": "e.g. Pre-empt Aergis by locking Helvetia at 14% effective before they sign the LoI. Anchor it on the 2022 Helvetia renewal — we have the Horizont-X UNI 11300 validation and a €500k/quarter penalty cap signed by Lange.",
213
+ "gameView.coach.charHint": "{count} / 4000 · Ctrl/Cmd+Enter to send",
214
+ "gameView.coach.start": "Talk to coach →",
215
+ "gameView.coach.starting": "Drafting…",
216
+ "gameView.coach.replyPlaceholder": "Your reply… (Ctrl/Cmd+Enter to send)",
217
+ "gameView.coach.replyHint": "{count} / 4000 · round {round} of 2",
218
+ "gameView.coach.send": "Send →",
219
+ "gameView.coach.sending": "Sending…",
220
+ "gameView.coach.busy.label": "Coach",
221
+ "gameView.coach.busy.suffix": "drafting…",
222
+ "gameView.coach.draft.eyebrowReady": "Draft action · ready to submit",
223
+ "gameView.coach.draft.eyebrowProgress": "Draft action · in progress ({rounds}/2 rounds)",
224
+ "gameView.coach.draft.footnote": "Submitting locks this move for Turn {turn}.",
225
+ "gameView.coach.approve.eyebrow": "Approve to submit",
226
+ "gameView.coach.approve.title": "Commit draft as your Turn {turn} action",
227
+ "gameView.coach.approve.editManual": "Edit in Manual →",
228
+ "gameView.coach.approve.restart": "Restart",
229
+ "gameView.coach.approve.submit": "Submit action →",
230
+ "gameView.coach.approve.submitting": "Submitting…",
231
+ "gameView.coach.restart.cta": "Restart conversation",
232
+ "gameView.coach.draft.intent": "Intent",
233
+ "gameView.coach.draft.why": "Why now",
234
+ "gameView.coach.draft.anchorsLabel": "Anchors · {count} of 3",
235
+ "gameView.coach.label.user": "You",
236
+ "gameView.coach.label.coach": "Coach",
237
+
238
+ // ---- GameView · verdict history ----
239
+ "gameView.verdicts.title": "Prior turns · the referee's ruling",
240
+ "gameView.verdicts.turn": "Turn {turn}",
241
+ "gameView.verdicts.resolved": "{count} verdicts resolved",
242
+ "gameView.verdicts.actionOnly": "action submitted",
243
+ "gameView.verdicts.fork": "Fork from Turn {turn} →",
244
+ "gameView.verdicts.forking": "Forking…",
245
+ "gameView.verdicts.fork.title": "Branch a new case from Turn {turn} and replay forward",
246
+ "gameView.verdicts.playerAction": "Player action",
247
+
248
+ // ---- GameView · orchestrator ----
249
+ "gameView.orch.eyebrow": "Scenario orchestrator · pre-play landscape",
250
+ "gameView.orch.title.complete": "See how three CEO profiles would play this turn.",
251
+ "gameView.orch.title.running": "Simulating three profiles in parallel.",
252
+ "gameView.orch.title.idle": "Before you commit, see how three profiles play this turn.",
253
+ "gameView.orch.show": "Show ↓",
254
+ "gameView.orch.hide": "Hide ↑",
255
+ "gameView.orch.idleDek": "Three strategy vectors will play a proxy version of this turn at different skill temperatures. You get a side-by-side outcome read before you commit your own move. Takes 3–5 minutes.",
256
+ "gameView.orch.run": "Run orchestrator →",
257
+ "gameView.orch.locked": "Orchestrator only runs pre-action — fork from Turn 1 to re-run.",
258
+ "gameView.orch.progress": "{done} / {total} branches complete",
259
+ "gameView.orch.progressDek": "Running in the background. You can read the briefing — this page will update live.",
260
+ "gameView.orch.vector.complete": "✓ complete",
261
+ "gameView.orch.vector.running": "⋯ running",
262
+ "gameView.orch.generatingVectors": "Generating strategy vectors…",
263
+ "gameView.orch.failed": "Orchestration failed: {error}",
264
+ "gameView.orch.failed.unknown": "unknown error",
265
+ "gameView.orch.retry": "Retry →",
266
+ "gameView.orch.rerun": "Re-run orchestrator →",
267
+ "gameView.orch.skipped.eyebrow": "Rejected",
268
+ "gameView.orch.skipped.fallback": "Anchor pre-check failed.",
269
+ "gameView.orch.played": "Played as",
270
+ "gameView.orch.adopt": "Use this opener →",
271
+ "gameView.orch.fullSynthesis": "Full synthesis · {chars} chars",
272
+
273
+ // ---- LiveProgress ----
274
+ "liveProgress.eyebrow": "Live · turn in flight",
275
+ "liveProgress.titleDefault": "Tracking deliberations and verdicts in real time.",
276
+ "liveProgress.titlePaused": "Paused — {label}",
277
+ "liveProgress.cost.aria": "cumulative cost",
278
+ "liveProgress.cost.suffix": "spent",
279
+ "liveProgress.paused.eyebrow": "Paused",
280
+ "liveProgress.paused.body": "Paused at end of {label}. Resume to continue.",
281
+ "liveProgress.streamErr.disconnected": "Live stream disconnected — refresh to reconnect.",
282
+ "liveProgress.engineErr.fallback": "Engine reported an error.",
283
+ "liveProgress.steps.deliberation": "Deliberation",
284
+ "liveProgress.steps.critic": "Critic",
285
+ "liveProgress.steps.premortem": "Pre-mortem",
286
+ "liveProgress.steps.adjudication": "Adjudication",
287
+ "liveProgress.steps.evaluator": "Evaluator",
288
+ "liveProgress.steps.worldUpdate": "World update",
289
+ "liveProgress.steps.nextBriefing": "Next briefing",
290
+ "liveProgress.rail.aria": "phase progress",
291
+ "liveProgress.deliberations": "Deliberations",
292
+ "liveProgress.deliberations.thinking": "Agents thinking…",
293
+ "liveProgress.deliberations.empty": "No agent deliberations yet.",
294
+ "liveProgress.deliberations.expects": "expects",
295
+ "liveProgress.deliberations.fears": "fears",
296
+ "liveProgress.verdicts": "Verdicts",
297
+ "liveProgress.verdicts.empty": "No verdicts adjudicated yet this turn.",
298
+ "liveProgress.verdict.plausibility": "P{n}",
299
+ "liveProgress.verdict.outcome.major": "effect · major",
300
+ "liveProgress.verdict.outcome.minor": "effect · minor",
301
+ "liveProgress.verdict.outcome.noEffect": "no effect",
302
+ "liveProgress.verdict.outcome.backfires": "backfires",
303
+
304
+ // ---- GameView · advance log ----
305
+ "gameView.advance.seed": "[{time}] Orchestrator: seeding agent deliberations for turn {turn}…",
306
+ "gameView.advance.resolved": "[{time}] ✓ Turn {turn} resolved",
307
+
308
+ // ---- SetupCase ----
309
+ "setup.eyebrow.lead": "New case",
310
+ "setup.eyebrow.tail": "design one with the setup agent",
311
+ "setup.intro.title": "Describe the situation. The agent will ask clarifying questions, then scaffold the scenario.",
312
+ "setup.intro.dek": "Write a paragraph or two about the company, the rival or threat, the stakes, and whatever constraints you already know. The setup agent will fill in the gaps: agent roster, world bible, starting metrics, hidden agendas. Takes a few rounds of Q&A (30–60s per round).",
313
+ "setup.caseId.label": "Case ID",
314
+ "setup.caseId.charCount": "{count} / 60",
315
+ "setup.caseId.placeholder": "e.g. nord_heatpump_takeover",
316
+ "setup.caseId.helper": "Lowercase letters, digits, underscores. Must start with a letter. 3–60 chars.",
317
+ "setup.caseId.invalid": "Invalid format.",
318
+ "setup.brief.label": "Situation brief",
319
+ "setup.brief.charCount": "{count} / 2000 (min 10)",
320
+ "setup.brief.placeholder": "e.g. We're a mid-sized German heat-pump maker, €680M revenue, 2800 employees. A Chinese entrant (Aergis Climate) just acquired an Italian subsidiary and is undercutting us on price at the EU commercial segment. Board wants a response for Q2. Regulatory pressure from F-gas phase-out plays in our favor on paper but the Betriebsrat is nervous about restructuring.",
321
+ "setup.start.helper": "The agent uses this as its starting context.",
322
+ "setup.start.cta": "Start conversation →",
323
+ "setup.start.busy": "Starting conversation…",
324
+ "setup.chat.eyebrow.lead": "Setup agent",
325
+ "setup.chat.eyebrow.case": "case",
326
+ "setup.chat.status.gathering": "Gathering context",
327
+ "setup.chat.status.ready": "Brief ready — review below",
328
+ "setup.chat.title.gathering": "Answer the agent’s questions. It’ll ask a few more then hand you a synthesized brief.",
329
+ "setup.chat.title.ready": "The agent has a complete brief. Review and commit to scaffold the scenario.",
330
+ "setup.chat.thinking": "thinking…",
331
+ "setup.chat.label.user": "You",
332
+ "setup.chat.label.agent": "Setup agent",
333
+ "setup.synthesis.eyebrow": "Synthesized brief · this is what will be scaffolded",
334
+ "setup.synthesis.footnote.lead": "Commit writes",
335
+ "setup.synthesis.footnote.tail": "— world_bible, agents.yaml, initial_state.json — then opens Turn 1.",
336
+ "setup.commit.eyebrow": "Approve to scaffold",
337
+ "setup.commit.title": "Commit brief & launch Turn 1",
338
+ "setup.commit.cta": "Commit & launch →",
339
+ "setup.committing.lead": "Scaffolding",
340
+ "setup.committing.tail": "and generating Turn 1…",
341
+ "setup.committing.helper": "This takes ~30–60s (scaffold + briefing). Don't navigate away.",
342
+ "setup.reply.placeholder": "Your answer… (Ctrl/Cmd+Enter to send)",
343
+ "setup.reply.charCount": "{count} / 4000",
344
+ "setup.reply.send": "Send →",
345
+ "setup.reply.sending": "Sending…",
346
+ "setup.docs.eyebrow": "Grounding documents",
347
+ "setup.docs.title": "Drop in source materials — the setup agent extracts hard constraints from these at commit time.",
348
+ "setup.docs.count": "{used} / {max}",
349
+ "setup.docs.uploading": "Uploading…",
350
+ "setup.docs.limitReached": "Limit reached — remove a document to upload another.",
351
+ "setup.docs.dropLead": "Drop documents here",
352
+ "setup.docs.dropTail": "or click to browse —",
353
+ "setup.docs.dropMeta": "max 25 MB each.",
354
+ "setup.docs.remove": "remove",
355
+ "setup.docs.cap": "Cap is {max} documents per session — currently {count} uploaded.",
356
+ "setup.docs.unsupported": "\"{file}\": only {exts} are accepted.",
357
+ "setup.docs.tooLarge": "\"{file}\": exceeds the 25 MB cap ({size}).",
358
+ "setup.docs.uploadFail": "\"{file}\": {message}",
359
+ "setup.docs.removeFail": "Could not remove \"{file}\": {message}",
360
+
361
+ // ---- Debrief ----
362
+ "debrief.eyebrow.label": "Debrief",
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": "Five turns 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…",
370
+ "debrief.dossier.tag": "Debrief · GET /games/{id}/debrief",
371
+ } as const;
372
+
373
+ export type StringKey = keyof typeof en;
frontend/src/i18n/index.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useSyncExternalStore } from 'react';
2
+ import { en, type StringKey } from './en';
3
+ import { de } from './de';
4
+
5
+ export type Language = 'en' | 'de';
6
+ export type { StringKey } from './en';
7
+
8
+ const STORAGE_KEY = 'wargame.lang';
9
+
10
+ const tables: Record<Language, Partial<Record<StringKey, string>>> = {
11
+ en,
12
+ de,
13
+ };
14
+
15
+ function readInitial(): Language {
16
+ try {
17
+ const stored = window.localStorage.getItem(STORAGE_KEY);
18
+ if (stored === 'de' || stored === 'en') return stored;
19
+ } catch {
20
+ // Storage may be unavailable (SSR / privacy mode); fall through.
21
+ }
22
+ return 'en';
23
+ }
24
+
25
+ let current: Language = readInitial();
26
+ const listeners = new Set<() => void>();
27
+
28
+ function emit() {
29
+ for (const fn of listeners) fn();
30
+ }
31
+
32
+ function subscribe(fn: () => void): () => void {
33
+ listeners.add(fn);
34
+ return () => listeners.delete(fn);
35
+ }
36
+
37
+ function getSnapshot(): Language {
38
+ return current;
39
+ }
40
+
41
+ export function getLanguage(): Language {
42
+ return current;
43
+ }
44
+
45
+ export function setLanguage(lang: Language): void {
46
+ if (lang !== 'en' && lang !== 'de') return;
47
+ if (current === lang) return;
48
+ current = lang;
49
+ try {
50
+ window.localStorage.setItem(STORAGE_KEY, lang);
51
+ } catch {
52
+ // Best-effort persist; the in-memory current still drives the UI.
53
+ }
54
+ emit();
55
+ }
56
+
57
+ function interpolate(template: string, vars?: Record<string, string | number>): string {
58
+ if (!vars) return template;
59
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
60
+ const v = vars[key];
61
+ return v === undefined || v === null ? `{${key}}` : String(v);
62
+ });
63
+ }
64
+
65
+ function lookup(lang: Language, key: StringKey): string {
66
+ const localized = tables[lang][key];
67
+ if (localized !== undefined) return localized;
68
+ const fallback = en[key];
69
+ if (fallback !== undefined) return fallback as string;
70
+ return key;
71
+ }
72
+
73
+ export function translate(
74
+ lang: Language,
75
+ key: StringKey,
76
+ vars?: Record<string, string | number>,
77
+ ): string {
78
+ return interpolate(lookup(lang, key), vars);
79
+ }
80
+
81
+ export function useI18n(): {
82
+ lang: Language;
83
+ t: (key: StringKey, vars?: Record<string, string | number>) => string;
84
+ setLang: (l: Language) => void;
85
+ } {
86
+ const lang = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
87
+ return {
88
+ lang,
89
+ t: (key, vars) => translate(lang, key, vars),
90
+ setLang: setLanguage,
91
+ };
92
+ }
frontend/src/screens/Debrief.tsx CHANGED
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
  import { api } from '../api';
5
  import { Dossier, Spinner } from '../components/Shell';
 
6
 
7
  interface Props {
8
  id: string;
@@ -10,11 +11,19 @@ interface Props {
10
  }
11
 
12
  export function Debrief({ id, onMeta }: Props) {
 
13
  const [md, setMd] = useState<string | null>(null);
14
  const [err, setErr] = useState<string | null>(null);
15
 
16
  useEffect(() => {
17
  api.debrief(id).then((r) => setMd(r.markdown)).catch((e) => setErr(String(e)));
 
 
 
 
 
 
 
18
  }, [id]);
19
 
20
  useEffect(() => {
@@ -27,28 +36,24 @@ export function Debrief({ id, onMeta }: Props) {
27
  <>
28
  <section className="nh-hero">
29
  <div className="nh-eyebrow">
30
- <span>Debrief</span>
31
  <span>·</span>
32
- <span className="mono">case {id.slice(0, 8)}…</span>
33
  <span>·</span>
34
- <b>Post-mortem</b>
35
  </div>
36
- <h1>The board's verdict, on the record.</h1>
37
- <p className="nh-dek">
38
- Five turns of deliberation, the rival playbook, the world-state arc, and the referee's reasoning —
39
- collated into a single report. Use it to compare openings, debug anchors, and line up next quarter's
40
- strategic review.
41
- </p>
42
  </section>
43
 
44
  {err && (
45
- <Dossier tag="Error" title="Debrief could not be loaded">
46
  <p style={{ color: 'var(--oxblood)' }}>{err}</p>
47
  </Dossier>
48
  )}
49
- {!md && !err && <p className="fg-3" style={{ padding: 20 }}><Spinner /> Loading debrief…</p>}
50
  {md && (
51
- <Dossier tag="Debrief · GET /games/{id}/debrief" title="">
52
  <div className="debrief-md">
53
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
54
  </div>
 
3
  import remarkGfm from 'remark-gfm';
4
  import { api } from '../api';
5
  import { Dossier, Spinner } from '../components/Shell';
6
+ import { setLanguage, useI18n, type Language } from '../i18n';
7
 
8
  interface Props {
9
  id: string;
 
11
  }
12
 
13
  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)));
20
+ // The debrief belongs to a specific game — switch the UI to that game's
21
+ // language for the duration of this screen. The shell toggle still lets
22
+ // the user override.
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(() => {
 
36
  <>
37
  <section className="nh-hero">
38
  <div className="nh-eyebrow">
39
+ <span>{t('debrief.eyebrow.label')}</span>
40
  <span>·</span>
41
+ <span className="mono">{t('debrief.eyebrow.case', { short: id.slice(0, 8) })}</span>
42
  <span>·</span>
43
+ <b>{t('debrief.eyebrow.tail')}</b>
44
  </div>
45
+ <h1>{t('debrief.title')}</h1>
46
+ <p className="nh-dek">{t('debrief.dek')}</p>
 
 
 
 
47
  </section>
48
 
49
  {err && (
50
+ <Dossier tag={t('debrief.errorTag')} title={t('debrief.errorTitle')}>
51
  <p style={{ color: 'var(--oxblood)' }}>{err}</p>
52
  </Dossier>
53
  )}
54
+ {!md && !err && <p className="fg-3" style={{ padding: 20 }}><Spinner /> {t('debrief.loading')}</p>}
55
  {md && (
56
+ <Dossier tag={t('debrief.dossier.tag', { id })} title="">
57
  <div className="debrief-md">
58
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
59
  </div>
frontend/src/screens/GameView.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
  import { navigate } from '../App';
10
  import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
11
  import { LiveProgress } from '../components/LiveProgress';
 
12
 
13
  interface Props {
14
  id: string;
@@ -25,6 +26,7 @@ type Phase = 'briefing' | 'recon' | 'action' | 'deliberation' | 'adjudication';
25
  * composer + live advance terminal.
26
  */
27
  export function GameView({ id, onMeta }: Props) {
 
28
  const [state, setState] = useState<any>(null);
29
  const [briefing, setBriefing] = useState<Briefing | null>(null);
30
  const [budget, setBudget] = useState<number>(3);
@@ -113,6 +115,19 @@ export function GameView({ id, onMeta }: Props) {
113
  }
114
  useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [id]);
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  // Hard constraints — load at mount so the panel + preflight stay in sync.
117
  async function loadConstraints() {
118
  try {
@@ -317,7 +332,7 @@ export function GameView({ id, onMeta }: Props) {
317
  const parsed = parseViolations(msg);
318
  if (status === 409 && parsed.length > 0) {
319
  setViolations(parsed);
320
- setCoachErr('Action blocked by hard constraints — see panel above the composer.');
321
  } else {
322
  setCoachErr(msg);
323
  }
@@ -355,9 +370,10 @@ export function GameView({ id, onMeta }: Props) {
355
  setConstraintsErr(null);
356
  const force = constraint.source !== 'manual';
357
  if (force) {
358
- const ok = window.confirm(
359
- `This constraint came from ${constraint.source === 'scenario' ? 'the scenario file' : 'an uploaded document'}.\n\nRemoving it bypasses an explicitly authored knowledge-base intent. Continue?`,
360
- );
 
361
  if (!ok) return;
362
  }
363
  try {
@@ -372,10 +388,10 @@ export function GameView({ id, onMeta }: Props) {
372
  setAdvanceBusy(true);
373
  setShowLive(true);
374
  setPhase('deliberation');
375
- setAdvanceLog([`[${nowHM()}] Orchestrator: seeding agent deliberations for turn ${turn}…`]);
376
  try {
377
  const r = await api.advance(id);
378
- setAdvanceLog((L) => [...L, `[${nowHM()}] ✓ Turn ${r.turn_resolved} resolved`]);
379
  if (r.status === 'paused') {
380
  // Engine hit a pause checkpoint; treat the same as a `paused` SSE
381
  // event. Refresh state so status flips and the Resume button shows.
@@ -465,14 +481,16 @@ export function GameView({ id, onMeta }: Props) {
465
 
466
  if (err) {
467
  return (
468
- <Dossier tag="Error" title="Case could not be loaded">
469
  <p style={{ color: 'var(--oxblood)' }}>{err}</p>
470
- <button className="btn ghost sm" onClick={() => { setErr(null); loadAll(); }}>Retry</button>
 
 
471
  </Dossier>
472
  );
473
  }
474
  if (!briefing) {
475
- return <p className="fg-3" style={{ padding: 20 }}><Spinner /> Loading briefing…</p>;
476
  }
477
 
478
  return (
@@ -480,18 +498,18 @@ export function GameView({ id, onMeta }: Props) {
480
  {/* HERO */}
481
  <section className="nh-hero">
482
  <div className="nh-eyebrow">
483
- <span>Turn {turn} — Exec Briefing</span>
484
  <span>·</span>
485
- <span>Case <span className="mono">{id.slice(0, 8)}…</span></span>
486
  <span>·</span>
487
  <b>
488
- {state?.status === 'paused' ? 'Paused — ready to resume' : (
489
  <>
490
- {phase === 'briefing' && 'Action phase open'}
491
- {phase === 'recon' && 'Recon phase'}
492
- {phase === 'action' && 'Action submitted — ready to advance'}
493
- {phase === 'deliberation' && 'Agents deliberating'}
494
- {phase === 'adjudication' && 'Adjudicating'}
495
  </>
496
  )}
497
  </b>
@@ -499,8 +517,8 @@ export function GameView({ id, onMeta }: Props) {
499
  <h1>{briefing.headline}</h1>
500
  <p className="nh-dek">{briefing.exec_summary}</p>
501
  <div className="nh-byline mono">
502
- Filed by Control · fog-of-war sections below · recon budget {budget} / 3
503
- {model && <> · engine <b>{model}</b></>}
504
  </div>
505
  </section>
506
 
@@ -523,12 +541,14 @@ export function GameView({ id, onMeta }: Props) {
523
  <div className="nh-grid">
524
  <div className="nh-narrative">
525
  {briefing.sections.map((s, i) => (
526
- <Dossier key={i} tag={`Section · ${s.heading}`} fog={!!s.fog_of_war}>
527
  <h3 className="nh-serif-h">{s.heading}</h3>
528
  <p style={{ whiteSpace: 'pre-wrap' }}>{s.body}</p>
529
  {s.fog_of_war && (
530
  <div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed var(--vellum)' }}>
531
- <span className="eyebrow" style={{ display: 'block', marginBottom: 6 }}>Fog of war · unconfirmed</span>
 
 
532
  <p className="body-sm" style={{ color: 'var(--slate)', fontStyle: 'italic' }}>{s.fog_of_war}</p>
533
  </div>
534
  )}
@@ -536,8 +556,10 @@ export function GameView({ id, onMeta }: Props) {
536
  ))}
537
 
538
  {briefing.decision_points.length > 0 && (
539
- <Dossier tag={`Decision points · Turn ${turn}`}>
540
- <h3 className="nh-serif-h">{briefing.decision_points.length} moves are on the table. Pick one to compose.</h3>
 
 
541
  <div className="nh-decisions">
542
  {briefing.decision_points.map((d, i) => (
543
  <div className="nh-decision" key={i}>
@@ -557,7 +579,7 @@ export function GameView({ id, onMeta }: Props) {
557
  setTimeout(() => document.querySelector('.nh-composer')
558
  ?.scrollIntoView({ block: 'start', behavior: 'smooth' }), 50);
559
  }}
560
- >Compose →</button>
561
  </div>
562
  ))}
563
  </div>
@@ -567,17 +589,17 @@ export function GameView({ id, onMeta }: Props) {
567
 
568
  {/* ASIDE */}
569
  <aside className="nh-aside">
570
- <Panel tag="Recon queue" title={`Budget ${budget} of 3 this turn.`}>
571
  {reconLog.length === 0 && (
572
  <p className="body-sm fg-3" style={{ marginTop: 10 }}>
573
- No queries spent yet this turn. Each query reveals the topic to the rival two turns from now.
574
  </p>
575
  )}
576
  <ul className="nh-recon">
577
  {reconLog.map((r, i) => (
578
  <li key={i}>
579
  <span className={`who mono ${r.reveals ? '' : 'done'}`}>
580
- Resolved · {r.who}{r.reveals ? ' · reveals to rival' : ''}
581
  </span>
582
  <span className="q">"{r.q}"</span>
583
  <span className="a">{r.a}</span>
@@ -586,7 +608,7 @@ export function GameView({ id, onMeta }: Props) {
586
  </ul>
587
  {budget > 0 ? (
588
  <div className="nh-recon__in">
589
- <label>Ask another agent</label>
590
  <div className="sel">
591
  <select value={reconAgent} onChange={(e) => setReconAgent(e.target.value)}>
592
  {((agents.length > 0
@@ -602,40 +624,48 @@ export function GameView({ id, onMeta }: Props) {
602
  <input
603
  value={reconQ}
604
  onChange={(e) => setReconQ(e.target.value)}
605
- placeholder="One concise question…"
606
  />
607
  </div>
608
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
609
- <span className="help mono">Each query costs 1 / 3.</span>
610
  <button
611
  className="btn primary sm"
612
  disabled={!reconQ.trim() || reconBusy}
613
  onClick={askRecon}
614
- >{reconBusy ? <><Spinner /> Asking…</> : 'Spend query →'}</button>
615
  </div>
616
  </div>
617
  ) : (
618
  <div className="nh-recon__in">
619
- <span className="help mono">Recon budget exhausted. Submit action and advance.</span>
620
  </div>
621
  )}
622
  </Panel>
623
 
624
  {(agents.length > 0 || state?.agents) && (
625
- <Panel tag="Agent roster" title="Voices in the room">
626
  <ul className="nh-agents">
627
- {(agents.length > 0 ? agents : state.agents).map((a: any) => (
628
- <li key={a.id}>
629
- <span className="code mono">{a.code || a.id.slice(0, 4).toUpperCase()}</span>
630
- <div>
631
- <div className="nm">{a.label || a.name || a.id}</div>
632
- <div className="rl">{a.posture || a.role || a.kind}</div>
633
- </div>
634
- <div className="rt">
635
- <span className={`kind ${a.kind === 'rival' ? 'rival' : ''}`}>{a.kind || 'advisor'}</span>
636
- </div>
637
- </li>
638
- ))}
 
 
 
 
 
 
 
 
639
  </ul>
640
  </Panel>
641
  )}
@@ -657,9 +687,9 @@ export function GameView({ id, onMeta }: Props) {
657
  {/* ACTION COMPOSER */}
658
  <section className={`nh-composer ${actionSent ? 'submitted' : ''}`}>
659
  <div className="nh-composer__hd">
660
- <h2>Your move, Turn {turn}</h2>
661
  {actionSent ? (
662
- <span className="hint">Submitted</span>
663
  ) : (
664
  <div role="tablist" aria-label="Composer mode" style={{ display: 'flex', gap: 6 }}>
665
  <button
@@ -668,26 +698,23 @@ export function GameView({ id, onMeta }: Props) {
668
  className={`btn sm ${composerMode === 'coach' ? 'primary' : 'ghost'}`}
669
  onClick={() => setComposerMode('coach')}
670
  disabled={submitBusy || coachBusy}
671
- >Talk to coach</button>
672
  <button
673
  role="tab"
674
  aria-selected={composerMode === 'manual'}
675
  className={`btn sm ${composerMode === 'manual' ? 'primary' : 'ghost'}`}
676
  onClick={() => setComposerMode('manual')}
677
  disabled={submitBusy || coachBusy}
678
- >Manual</button>
679
  </div>
680
  )}
681
  </div>
682
 
683
  {actionSent ? (
684
  <>
685
- <p className="fg-2">
686
- Action submitted for Turn {turn}. Advance when you're ready — the model writes verdicts against
687
- your agent roster and the rival playbook. This takes 2–5 minutes.
688
- </p>
689
  <div className="nh-submit">
690
- <span className="hint mono fg-3">Advancing is irreversible. Make sure your anchors are complete.</span>
691
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
692
  {state?.status === 'paused' ? (
693
  <button
@@ -695,7 +722,9 @@ export function GameView({ id, onMeta }: Props) {
695
  onClick={resumeAdvance}
696
  disabled={resumeBusy}
697
  >
698
- {resumeBusy ? <><Spinner /> Resuming…</> : 'Resume turn →'}
 
 
699
  </button>
700
  ) : (
701
  <>
@@ -704,13 +733,17 @@ export function GameView({ id, onMeta }: Props) {
704
  className="btn ghost sm"
705
  onClick={pauseAdvance}
706
  disabled={pauseBusy}
707
- title="Stop at the next phase checkpoint"
708
  >
709
- {pauseBusy ? <><Spinner /> Pausing…</> : 'Pause'}
 
 
710
  </button>
711
  )}
712
  <button className="btn primary" onClick={advance} disabled={advanceBusy}>
713
- {advanceBusy ? <><Spinner /> Resolving turn…</> : 'Advance turn →'}
 
 
714
  </button>
715
  </>
716
  )}
@@ -754,26 +787,34 @@ export function GameView({ id, onMeta }: Props) {
754
  ) : (
755
  <>
756
  <div className="nh-formrow">
757
- <label>Intent <span className="ct mono">{intent.length} / 160</span></label>
 
 
 
758
  <input
759
  value={intent} maxLength={160}
760
  onChange={(e) => setIntent(e.target.value)}
761
- placeholder="e.g. Lock Helvetia at 14% effective before Aergis signs an LoI."
762
  />
763
  </div>
764
  <div className="nh-formrow">
765
- <label>Why now <span className="ct mono">{why.length} / 400</span></label>
 
 
 
766
  <textarea
767
  value={why} maxLength={400}
768
  onChange={(e) => setWhy(e.target.value)}
769
- placeholder="The strategic reasoning. What changes if you act vs. wait one turn."
770
  />
771
  </div>
772
 
773
  <div className="nh-anchors">
774
  <div className="legend">
775
- <span>Anchors · each action must cite three</span>
776
- <span className={`count ${anchorsFilled >= 3 ? 'ok' : ''}`}>{anchorsFilled} / 3</span>
 
 
777
  </div>
778
  {anchors.map((a, i) => (
779
  <div className="nh-anchor editable" key={i}>
@@ -790,11 +831,11 @@ export function GameView({ id, onMeta }: Props) {
790
  setAnchors(v);
791
  }}
792
  >
793
- <option value="prior_analogue">Prior analogue</option>
794
- <option value="stated_capability">Stated capability</option>
795
- <option value="resource_committed">Resource committed</option>
796
  </select>
797
- <div className="hint">{ANCHOR_HINTS[a.kind]}</div>
798
  </div>
799
  <input
800
  value={a.detail}
@@ -803,13 +844,13 @@ export function GameView({ id, onMeta }: Props) {
803
  v[i] = { ...a, detail: e.target.value };
804
  setAnchors(v);
805
  }}
806
- placeholder={ANCHOR_PLACEHOLDERS[a.kind]}
807
  />
808
  <button
809
  className="rm"
810
  onClick={() => setAnchors(anchors.filter((_, j) => j !== i))}
811
  disabled={anchors.length <= 3}
812
- >remove</button>
813
  </div>
814
  ))}
815
  {anchors.length < 5 && (
@@ -817,7 +858,7 @@ export function GameView({ id, onMeta }: Props) {
817
  <button
818
  className="btn ghost sm"
819
  onClick={() => setAnchors([...anchors, { kind: 'resource_committed', detail: '' }])}
820
- >+ Add a fourth anchor (optional)</button>
821
  </div>
822
  )}
823
  </div>
@@ -832,10 +873,10 @@ export function GameView({ id, onMeta }: Props) {
832
  <div className="nh-submit">
833
  <span className="hint mono fg-3">
834
  {violations.length > 0
835
- ? 'Resolve the constraint violations above to unlock submit.'
836
  : preflightBusy
837
- ? 'Checking against hard constraints…'
838
- : `Submitting locks your move for Turn ${turn}. Advance after submission.`}
839
  </span>
840
  <button
841
  className="btn primary"
@@ -843,8 +884,8 @@ export function GameView({ id, onMeta }: Props) {
843
  onClick={submitAction}
844
  >
845
  {submitBusy
846
- ? <><Spinner /> {preflightBusy ? 'Checking…' : 'Submitting…'}</>
847
- : 'Submit action →'}
848
  </button>
849
  </div>
850
  </>
@@ -873,15 +914,15 @@ export function GameView({ id, onMeta }: Props) {
873
  );
874
  }
875
 
876
- const ANCHOR_HINTS: Record<AnchorKind, string> = {
877
- prior_analogue: 'A past case — yours or someone else\'s — where this worked.',
878
- stated_capability: 'A capability you already have, on the record.',
879
- resource_committed: 'Specific money, headcount, or time you\'re putting behind this.',
880
  };
881
- const ANCHOR_PLACEHOLDERS: Record<AnchorKind, string> = {
882
- prior_analogue: 'e.g. Helvetia renewal 2022 — 16% effective, 3-year auto-renew',
883
- stated_capability: 'e.g. Horizont-X COP 3.4 validated under UNI 11300 (Jun 2024)',
884
- resource_committed: 'e.g. €500k/quarter penalty cap, signed by Lange',
885
  };
886
 
887
  const DEFAULT_AGENTS = [
@@ -933,37 +974,42 @@ function VerdictHistory({
933
  forkBusy: number | null;
934
  onFork: (fromTurn: number) => void;
935
  }) {
 
936
  const resolved = turns.filter(
937
- (t) => (t?.verdicts?.length || 0) > 0 || !!t?.player_action,
938
  );
939
  if (!resolved.length) return null;
940
  return (
941
  <section className="nh-verdicts">
942
- <h2>Prior turns · the referee's ruling</h2>
943
- {resolved.map((t, i) => {
944
- const action = t.player_action;
945
- const verdictCount = t?.verdicts?.length || 0;
946
  return (
947
  <div className="nh-vturn" key={i}>
948
  <div className="nh-vturn__hd">
949
- <span className="mono eyebrow">Turn {t.turn}</span>
950
  <span className="mono fg-3">
951
- {verdictCount > 0 ? `${verdictCount} verdicts resolved` : 'action submitted'}
 
 
952
  </span>
953
  <span className="spacer" style={{ flex: 1 }} />
954
  <button
955
  className="btn ghost sm"
956
  disabled={forkBusy !== null}
957
- onClick={() => onFork(t.turn)}
958
- title={`Branch a new case from Turn ${t.turn} and replay forward`}
959
  >
960
- {forkBusy === t.turn ? <><Spinner /> Forking…</> : `Fork from Turn ${t.turn} →`}
 
 
961
  </button>
962
  </div>
963
  {action && (
964
  <div className="nh-v">
965
  <div className="nh-v__hd">
966
- <span className="actor mono">Player action</span>
967
  </div>
968
  <p style={{ whiteSpace: 'pre-wrap' }}>
969
  <strong>{action.intent}</strong>
@@ -989,6 +1035,7 @@ function OrchestratorBar({
989
  onAdopt: (branch: ScenarioMap['branches'][number]) => void;
990
  canRun: boolean;
991
  }) {
 
992
  const running = map?.status === 'running' || map?.status === 'pending';
993
  const failed = map?.status === 'failed';
994
  const complete = map?.status === 'complete';
@@ -1009,18 +1056,18 @@ function OrchestratorBar({
1009
  <section className="nh-orch">
1010
  <div className="nh-orch__hd">
1011
  <div>
1012
- <span className="eyebrow">Scenario orchestrator · pre-play landscape</span>
1013
  <h2 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
1014
  {complete
1015
- ? 'See how three CEO profiles would play this turn.'
1016
  : running
1017
- ? 'Simulating three profiles in parallel.'
1018
- : 'Before you commit, see how three profiles play this turn.'}
1019
  </h2>
1020
  </div>
1021
  {(complete || failed) && (
1022
  <button className="btn ghost sm" onClick={onToggle}>
1023
- {open ? 'Hide ↑' : 'Show ↓'}
1024
  </button>
1025
  )}
1026
  </div>
@@ -1031,17 +1078,14 @@ function OrchestratorBar({
1031
  {!map && !error && (
1032
  <div className="nh-orch__idle">
1033
  <p className="fg-2" style={{ margin: 0 }}>
1034
- Three strategy vectors will play a proxy version of this turn at different skill temperatures.
1035
- You get a side-by-side outcome read before you commit your own move. Takes 3–5 minutes.
1036
  </p>
1037
  {canRun ? (
1038
  <button className="btn primary" onClick={onRun}>
1039
- Run orchestrator →
1040
  </button>
1041
  ) : (
1042
- <span className="help mono fg-3">
1043
- Orchestrator only runs pre-action — fork from Turn 1 to re-run.
1044
- </span>
1045
  )}
1046
  </div>
1047
  )}
@@ -1053,11 +1097,9 @@ function OrchestratorBar({
1053
  <Spinner />
1054
  <div>
1055
  <div className="mono">
1056
- {map?.branches_complete ?? 0} / {total} branches complete
1057
- </div>
1058
- <div className="help mono fg-3">
1059
- Running in the background. You can read the briefing — this page will update live.
1060
  </div>
 
1061
  </div>
1062
  </div>
1063
  {vectors.length > 0 && (
@@ -1072,7 +1114,7 @@ function OrchestratorBar({
1072
  <div className="nh-orch__vhd">
1073
  <span className="code mono">{v.id.slice(0, 4).toUpperCase()}</span>
1074
  <span className="mono fg-3" style={{ fontSize: 10.5 }}>
1075
- {done ? '✓ complete' : '⋯ running'}
1076
  </span>
1077
  </div>
1078
  <div className="nh-orch__vlabel">{v.label}</div>
@@ -1083,7 +1125,7 @@ function OrchestratorBar({
1083
  )}
1084
  {vectors.length === 0 && (
1085
  <div className="help fg-3 mono" style={{ marginTop: 8 }}>
1086
- Generating strategy vectors…
1087
  </div>
1088
  )}
1089
  </div>
@@ -1092,13 +1134,13 @@ function OrchestratorBar({
1092
  {/* FAILED */}
1093
  {failed && open && (
1094
  <div className="nh-orch__failed">
1095
- <p style={{ color: 'var(--oxblood)' }}>Orchestration failed: {map?.error || 'unknown error'}</p>
 
 
1096
  {canRun ? (
1097
- <button className="btn ghost sm" onClick={onRun}>Retry →</button>
1098
  ) : (
1099
- <span className="help mono fg-3">
1100
- Orchestrator only runs pre-action — fork from Turn 1 to re-run.
1101
- </span>
1102
  )}
1103
  </div>
1104
  )}
@@ -1124,8 +1166,8 @@ function OrchestratorBar({
1124
  <p className="nh-orch__cdesc">{v.description}</p>
1125
  {skipped ? (
1126
  <div className="nh-orch__cskip">
1127
- <span className="eyebrow">Rejected</span>
1128
- <div className="help">{b?.skip_reason || 'Anchor pre-check failed.'}</div>
1129
  </div>
1130
  ) : (
1131
  <>
@@ -1144,7 +1186,7 @@ function OrchestratorBar({
1144
  )}
1145
  {action?.intent && (
1146
  <div className="nh-orch__cplay">
1147
- <span className="eyebrow">Played as</span>
1148
  <div>{action.intent}</div>
1149
  </div>
1150
  )}
@@ -1154,7 +1196,7 @@ function OrchestratorBar({
1154
  onClick={() => b && onAdopt(b)}
1155
  style={{ marginTop: 'auto' }}
1156
  >
1157
- Use this opener →
1158
  </button>
1159
  )}
1160
  </>
@@ -1165,7 +1207,11 @@ function OrchestratorBar({
1165
  </div>
1166
  {map?.synthesis_markdown && (
1167
  <details className="nh-orch__full">
1168
- <summary>Full synthesis · {map.synthesis_markdown.length.toLocaleString()} chars</summary>
 
 
 
 
1169
  <div className="setup-md" style={{ fontSize: 14, lineHeight: 1.6, marginTop: 10 }}>
1170
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{map.synthesis_markdown}</ReactMarkdown>
1171
  </div>
@@ -1173,11 +1219,9 @@ function OrchestratorBar({
1173
  )}
1174
  <div className="nh-orch__ft">
1175
  {canRun ? (
1176
- <button className="btn ghost sm" onClick={onRun}>Re-run orchestrator →</button>
1177
  ) : (
1178
- <span className="help mono fg-3">
1179
- Orchestrator only runs pre-action — fork from Turn 1 to re-run.
1180
- </span>
1181
  )}
1182
  </div>
1183
  </>
@@ -1228,6 +1272,7 @@ function ActionCoachThread({
1228
  onSwitchToManualWithDraft: () => void;
1229
  turn: number;
1230
  }) {
 
1231
  const hasSession = session !== null;
1232
  const ready = session?.status === 'ready';
1233
  const draft = session?.synthesized_draft ?? null;
@@ -1239,16 +1284,14 @@ function ActionCoachThread({
1239
  // Intro — collect initial prose before kicking off the coach.
1240
  <div className="nh-chat__input">
1241
  <p className="fg-2" style={{ marginTop: 0, marginBottom: 12 }}>
1242
- Describe your move in plain prose. The coach will draft a structured action with three anchors —
1243
- the matrix-game umpire scores plausibility against those anchors, so specificity matters. If a
1244
- critical anchor is ambiguous, the coach will ask one focused question. Capped at two rounds.
1245
  </p>
1246
  <form onSubmit={(e) => { e.preventDefault(); onStart(); }}>
1247
  <textarea
1248
  value={input}
1249
  rows={5}
1250
  maxLength={4000}
1251
- placeholder="e.g. Pre-empt Aergis by locking Helvetia at 14% effective before they sign the LoI. Anchor it on the 2022 Helvetia renewal — we have the Horizont-X UNI 11300 validation and a EUR 500k/quarter penalty cap signed by Lange."
1252
  onChange={(e) => setInput(e.target.value)}
1253
  onKeyDown={(e) => {
1254
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
@@ -1258,13 +1301,15 @@ function ActionCoachThread({
1258
  disabled={busy}
1259
  />
1260
  <div className="nh-chat__submit">
1261
- <span className="hint">{input.length} / 4000 · Ctrl/Cmd+Enter to send</span>
1262
  <button
1263
  type="submit"
1264
  className="btn primary"
1265
  disabled={!input.trim() || busy}
1266
  >
1267
- {busy ? <><Spinner /> Drafting…</> : 'Talk to coach →'}
 
 
1268
  </button>
1269
  </div>
1270
  </form>
@@ -1278,7 +1323,9 @@ function ActionCoachThread({
1278
  ))}
1279
  {busy && (
1280
  <div className="nh-msg control">
1281
- <div className="nh-msg__meta"><span>Coach</span> drafting…</div>
 
 
1282
  <div className="nh-msg__body"><Spinner /></div>
1283
  </div>
1284
  )}
@@ -1288,11 +1335,13 @@ function ActionCoachThread({
1288
  {draft && (
1289
  <div className="nh-spec">
1290
  <span className="eyebrow">
1291
- Draft action {ready ? '· ready to submit' : `· in progress (${session?.rounds_used ?? 0}/2 rounds)`}
 
 
1292
  </span>
1293
  <CoachDraftCard draft={draft} />
1294
  <div className="nh-spec__fn" style={{ marginTop: 6 }}>
1295
- Submitting locks this move for Turn {turn}.
1296
  </div>
1297
  </div>
1298
  )}
@@ -1300,18 +1349,20 @@ function ActionCoachThread({
1300
  {ready ? (
1301
  <div className="nh-chat__approve">
1302
  <div>
1303
- <span className="eyebrow">Approve to submit</span>
1304
- <div className="hd">Commit draft as your Turn {turn} action</div>
1305
  </div>
1306
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
1307
  <button className="btn ghost sm" onClick={onSwitchToManualWithDraft} disabled={busy}>
1308
- Edit in Manual →
1309
  </button>
1310
  <button className="btn ghost sm" onClick={onReset} disabled={busy}>
1311
- Restart
1312
  </button>
1313
  <button className="btn primary" onClick={onCommit} disabled={busy}>
1314
- {busy ? <><Spinner /> Submitting…</> : 'Submit action →'}
 
 
1315
  </button>
1316
  </div>
1317
  </div>
@@ -1322,7 +1373,7 @@ function ActionCoachThread({
1322
  value={input}
1323
  rows={4}
1324
  maxLength={4000}
1325
- placeholder="Your reply… (Ctrl/Cmd+Enter to send)"
1326
  onChange={(e) => setInput(e.target.value)}
1327
  onKeyDown={(e) => {
1328
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
@@ -1333,20 +1384,25 @@ function ActionCoachThread({
1333
  />
1334
  <div className="nh-chat__submit">
1335
  <span className="hint">
1336
- {input.length} / 4000 · round {(session?.rounds_used ?? 0) + 1} of 2
 
 
 
1337
  </span>
1338
  <button
1339
  type="submit"
1340
  className="btn primary"
1341
  disabled={!input.trim() || busy}
1342
  >
1343
- {busy ? <><Spinner /> Sending…</> : 'Send →'}
 
 
1344
  </button>
1345
  </div>
1346
  </form>
1347
  <div style={{ marginTop: 8 }}>
1348
  <button className="btn ghost sm" onClick={onReset} disabled={busy}>
1349
- Restart conversation
1350
  </button>
1351
  </div>
1352
  </div>
@@ -1360,8 +1416,9 @@ function ActionCoachThread({
1360
  }
1361
 
1362
  function CoachMsg({ m }: { m: CoachMessage }) {
 
1363
  const who = m.role === 'user' ? 'user' : 'control';
1364
- const label = m.role === 'user' ? 'You' : 'Coach';
1365
  return (
1366
  <div className={`nh-msg ${who}`}>
1367
  <div className="nh-msg__meta"><span>{label}</span> {fmtCoachAt(m.at)}</div>
@@ -1379,18 +1436,21 @@ function CoachMsg({ m }: { m: CoachMessage }) {
1379
  }
1380
 
1381
  function CoachDraftCard({ draft }: { draft: NonNullable<ActionCoachSession['synthesized_draft']> }) {
 
1382
  return (
1383
  <div style={{ display: 'grid', gap: 10, fontSize: 14, lineHeight: 1.55 }}>
1384
  <div>
1385
- <div className="eyebrow" style={{ marginBottom: 2 }}>Intent</div>
1386
  <div style={{ fontWeight: 600 }}>{draft.intent}</div>
1387
  </div>
1388
  <div>
1389
- <div className="eyebrow" style={{ marginBottom: 2 }}>Why now</div>
1390
  <div>{draft.justification}</div>
1391
  </div>
1392
  <div>
1393
- <div className="eyebrow" style={{ marginBottom: 4 }}>Anchors · {draft.anchors.length} of 3</div>
 
 
1394
  <ul style={{ margin: 0, paddingLeft: 18 }}>
1395
  {draft.anchors.map((a, i) => (
1396
  <li key={i} style={{ marginBottom: 4 }}>
@@ -1423,6 +1483,7 @@ function ConstraintsPanel({
1423
  onAdd: (input: { description: string; rationale: string; test_question: string; severity: ConstraintSeverity }) => Promise<void>;
1424
  onDelete: (constraint: HardConstraint) => void | Promise<void>;
1425
  }) {
 
1426
  const [adding, setAdding] = useState(false);
1427
  const [draft, setDraft] = useState({
1428
  description: '',
@@ -1468,14 +1529,18 @@ function ConstraintsPanel({
1468
  aria-expanded={open}
1469
  >
1470
  <div>
1471
- <span className="eyebrow">Hard constraints</span>
1472
  <div className="hd">
1473
  {constraints.length === 0
1474
- ? 'No constraints declared on this game'
1475
- : `${hardCount} hard${softCount > 0 ? ` · ${softCount} soft` : ''} · enforced at submit time`}
 
 
1476
  </div>
1477
  </div>
1478
- <span className="mono fg-3">{open ? 'Hide ↑' : 'Show ↓'}</span>
 
 
1479
  </button>
1480
 
1481
  {open && (
@@ -1483,8 +1548,7 @@ function ConstraintsPanel({
1483
  {err && <p className="nh-constraints__err">{err}</p>}
1484
  {constraints.length === 0 ? (
1485
  <p className="fg-3" style={{ margin: '0 0 12px' }}>
1486
- No hard constraints are active. Add one below, or upload grounding documents at setup time
1487
- and let the setup agent extract them automatically.
1488
  </p>
1489
  ) : (
1490
  <ul className="nh-constraint-list">
@@ -1497,44 +1561,53 @@ function ConstraintsPanel({
1497
  {adding ? (
1498
  <div className="nh-constraint-add">
1499
  <div className="nh-formrow">
1500
- <label>Description <span className="ct mono">{draft.description.length} / 400</span></label>
 
 
 
1501
  <textarea
1502
  value={draft.description}
1503
  maxLength={400}
1504
  rows={2}
1505
  onChange={(e) => setDraft({ ...draft, description: e.target.value })}
1506
- placeholder="e.g. The Helvetia MSA forbids price increases above 4% YoY without 90-day notice."
1507
  />
1508
  </div>
1509
  <div className="nh-formrow">
1510
- <label>Rationale <span className="ct mono">{draft.rationale.length} / 400</span></label>
 
 
 
1511
  <textarea
1512
  value={draft.rationale}
1513
  maxLength={400}
1514
  rows={2}
1515
  onChange={(e) => setDraft({ ...draft, rationale: e.target.value })}
1516
- placeholder="e.g. Breach triggers a EUR 12M penalty clause and a public arbitration filing."
1517
  />
1518
  </div>
1519
  <div className="nh-formrow">
1520
- <label>Test question <span className="ct mono">{draft.test_question.length} / 300</span></label>
 
 
 
1521
  <textarea
1522
  value={draft.test_question}
1523
  maxLength={300}
1524
  rows={2}
1525
  onChange={(e) => setDraft({ ...draft, test_question: e.target.value })}
1526
- placeholder='e.g. "Does this action raise prices on the Helvetia contract by more than 4% without 90-day notice?"'
1527
  />
1528
  </div>
1529
  <div className="nh-formrow">
1530
- <label>Severity</label>
1531
  <select
1532
  value={draft.severity}
1533
  onChange={(e) => setDraft({ ...draft, severity: e.target.value as ConstraintSeverity })}
1534
  style={{ font: 'inherit' }}
1535
  >
1536
- <option value="hard">Hard - block any action that breaches</option>
1537
- <option value="soft">Soft - flag but allow</option>
1538
  </select>
1539
  </div>
1540
  {localErr && <p className="nh-constraints__err">{localErr}</p>}
@@ -1544,24 +1617,26 @@ function ConstraintsPanel({
1544
  className="btn ghost sm"
1545
  onClick={() => { setAdding(false); setLocalErr(null); }}
1546
  disabled={busy}
1547
- >Cancel</button>
1548
  <button
1549
  type="button"
1550
  className="btn primary"
1551
  onClick={submit}
1552
  disabled={!draftValid || busy}
1553
  >
1554
- {busy ? <><Spinner /> Adding…</> : 'Add constraint →'}
 
 
1555
  </button>
1556
  </div>
1557
  </div>
1558
  ) : (
1559
  <div className="nh-constraints__addbar">
1560
  <span className="hint mono fg-3">
1561
- Hard constraints block any action that breaches them at submission time.
1562
  </span>
1563
  <button type="button" className="btn ghost sm" onClick={() => setAdding(true)}>
1564
- + Add constraint
1565
  </button>
1566
  </div>
1567
  )}
@@ -1577,6 +1652,7 @@ function ConstraintRow({
1577
  c: HardConstraint;
1578
  onDelete: () => void | Promise<void>;
1579
  }) {
 
1580
  const [expanded, setExpanded] = useState(false);
1581
  const long = c.description.length > 120;
1582
  const display = expanded || !long ? c.description : `${c.description.slice(0, 117)}…`;
@@ -1589,7 +1665,9 @@ function ConstraintRow({
1589
  type="button"
1590
  className="nh-constraint__desc"
1591
  onClick={() => long && setExpanded((v) => !v)}
1592
- title={long ? (expanded ? 'Click to collapse' : 'Click to expand') : c.description}
 
 
1593
  >
1594
  {display}
1595
  </button>
@@ -1597,17 +1675,23 @@ function ConstraintRow({
1597
  type="button"
1598
  className="nh-constraint__rm"
1599
  onClick={onDelete}
1600
- title="Remove constraint"
1601
  >
1602
- remove
1603
  </button>
1604
  </div>
1605
  {expanded && (
1606
  <div className="nh-constraint__detail">
1607
- <div><span className="eyebrow">Rationale</span> {c.rationale}</div>
1608
- <div><span className="eyebrow">Test question</span> <span className="mono">{c.test_question}</span></div>
 
 
 
1609
  {c.source_ref && (
1610
- <div><span className="eyebrow">Source ref</span> <span className="mono">{c.source_ref}</span></div>
 
 
 
1611
  )}
1612
  </div>
1613
  )}
@@ -1621,11 +1705,14 @@ function ViolationsBanner({
1621
  violations: ConstraintViolation[];
1622
  onDismiss: () => void;
1623
  }) {
 
1624
  return (
1625
  <div className="nh-violations" role="alert">
1626
  <div className="nh-violations__hd">
1627
- <span className="eyebrow">Hard constraint violation · action blocked</span>
1628
- <button type="button" className="nh-violations__rm" onClick={onDismiss}>dismiss</button>
 
 
1629
  </div>
1630
  <ul>
1631
  {violations.map((v, i) => (
@@ -1636,7 +1723,7 @@ function ViolationsBanner({
1636
  ))}
1637
  </ul>
1638
  <p className="hint mono fg-3" style={{ marginTop: 6 }}>
1639
- Revise the action above and the next submit attempt will be re-checked.
1640
  </p>
1641
  </div>
1642
  );
 
9
  import { navigate } from '../App';
10
  import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
11
  import { LiveProgress } from '../components/LiveProgress';
12
+ import { setLanguage, useI18n, type Language, type StringKey } from '../i18n';
13
 
14
  interface Props {
15
  id: string;
 
26
  * composer + live advance terminal.
27
  */
28
  export function GameView({ id, onMeta }: Props) {
29
+ const { t } = useI18n();
30
  const [state, setState] = useState<any>(null);
31
  const [briefing, setBriefing] = useState<Briefing | null>(null);
32
  const [budget, setBudget] = useState<number>(3);
 
115
  }
116
  useEffect(() => { loadAll(); /* eslint-disable-next-line */ }, [id]);
117
 
118
+ // Sync the UI language with the game's recorded language *once per game*.
119
+ // After mount, the shell EN|DE toggle still works — we don't override it
120
+ // on every advance reload.
121
+ useEffect(() => {
122
+ let cancelled = false;
123
+ api.getState(id).then((s) => {
124
+ if (cancelled) return;
125
+ const gameLang = s.language as Language | undefined;
126
+ if (gameLang === 'en' || gameLang === 'de') setLanguage(gameLang);
127
+ }).catch(() => { /* non-fatal */ });
128
+ return () => { cancelled = true; };
129
+ }, [id]);
130
+
131
  // Hard constraints — load at mount so the panel + preflight stay in sync.
132
  async function loadConstraints() {
133
  try {
 
332
  const parsed = parseViolations(msg);
333
  if (status === 409 && parsed.length > 0) {
334
  setViolations(parsed);
335
+ setCoachErr(t('gameView.violations.coachBlocked'));
336
  } else {
337
  setCoachErr(msg);
338
  }
 
370
  setConstraintsErr(null);
371
  const force = constraint.source !== 'manual';
372
  if (force) {
373
+ const message = constraint.source === 'scenario'
374
+ ? t('gameView.constraints.delete.confirm.scenario')
375
+ : t('gameView.constraints.delete.confirm.document');
376
+ const ok = window.confirm(message);
377
  if (!ok) return;
378
  }
379
  try {
 
388
  setAdvanceBusy(true);
389
  setShowLive(true);
390
  setPhase('deliberation');
391
+ setAdvanceLog([t('gameView.advance.seed', { time: nowHM(), turn })]);
392
  try {
393
  const r = await api.advance(id);
394
+ setAdvanceLog((L) => [...L, t('gameView.advance.resolved', { time: nowHM(), turn: r.turn_resolved })]);
395
  if (r.status === 'paused') {
396
  // Engine hit a pause checkpoint; treat the same as a `paused` SSE
397
  // event. Refresh state so status flips and the Resume button shows.
 
481
 
482
  if (err) {
483
  return (
484
+ <Dossier tag={t('gameView.errorTag')} title={t('gameView.errorTitle')}>
485
  <p style={{ color: 'var(--oxblood)' }}>{err}</p>
486
+ <button className="btn ghost sm" onClick={() => { setErr(null); loadAll(); }}>
487
+ {t('gameView.retry')}
488
+ </button>
489
  </Dossier>
490
  );
491
  }
492
  if (!briefing) {
493
+ return <p className="fg-3" style={{ padding: 20 }}><Spinner /> {t('gameView.loading')}</p>;
494
  }
495
 
496
  return (
 
498
  {/* HERO */}
499
  <section className="nh-hero">
500
  <div className="nh-eyebrow">
501
+ <span>{t('gameView.eyebrow.execBriefing', { turn })}</span>
502
  <span>·</span>
503
+ <span>{t('gameView.eyebrow.case')} <span className="mono">{id.slice(0, 8)}…</span></span>
504
  <span>·</span>
505
  <b>
506
+ {state?.status === 'paused' ? t('gameView.status.paused') : (
507
  <>
508
+ {phase === 'briefing' && t('gameView.status.action')}
509
+ {phase === 'recon' && t('gameView.status.recon')}
510
+ {phase === 'action' && t('gameView.status.actionSubmitted')}
511
+ {phase === 'deliberation' && t('gameView.status.deliberating')}
512
+ {phase === 'adjudication' && t('gameView.status.adjudicating')}
513
  </>
514
  )}
515
  </b>
 
517
  <h1>{briefing.headline}</h1>
518
  <p className="nh-dek">{briefing.exec_summary}</p>
519
  <div className="nh-byline mono">
520
+ {t('gameView.byline.lead', { budget })}
521
+ {model && <> · {t('gameView.byline.engine')} <b>{model}</b></>}
522
  </div>
523
  </section>
524
 
 
541
  <div className="nh-grid">
542
  <div className="nh-narrative">
543
  {briefing.sections.map((s, i) => (
544
+ <Dossier key={i} tag={t('gameView.section.tag', { heading: s.heading })} fog={!!s.fog_of_war}>
545
  <h3 className="nh-serif-h">{s.heading}</h3>
546
  <p style={{ whiteSpace: 'pre-wrap' }}>{s.body}</p>
547
  {s.fog_of_war && (
548
  <div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed var(--vellum)' }}>
549
+ <span className="eyebrow" style={{ display: 'block', marginBottom: 6 }}>
550
+ {t('gameView.fog.label')}
551
+ </span>
552
  <p className="body-sm" style={{ color: 'var(--slate)', fontStyle: 'italic' }}>{s.fog_of_war}</p>
553
  </div>
554
  )}
 
556
  ))}
557
 
558
  {briefing.decision_points.length > 0 && (
559
+ <Dossier tag={t('gameView.decisions.tag', { turn })}>
560
+ <h3 className="nh-serif-h">
561
+ {t('gameView.decisions.title', { count: briefing.decision_points.length })}
562
+ </h3>
563
  <div className="nh-decisions">
564
  {briefing.decision_points.map((d, i) => (
565
  <div className="nh-decision" key={i}>
 
579
  setTimeout(() => document.querySelector('.nh-composer')
580
  ?.scrollIntoView({ block: 'start', behavior: 'smooth' }), 50);
581
  }}
582
+ >{t('gameView.decisions.compose')}</button>
583
  </div>
584
  ))}
585
  </div>
 
589
 
590
  {/* ASIDE */}
591
  <aside className="nh-aside">
592
+ <Panel tag={t('gameView.recon.tag')} title={t('gameView.recon.title', { budget })}>
593
  {reconLog.length === 0 && (
594
  <p className="body-sm fg-3" style={{ marginTop: 10 }}>
595
+ {t('gameView.recon.empty')}
596
  </p>
597
  )}
598
  <ul className="nh-recon">
599
  {reconLog.map((r, i) => (
600
  <li key={i}>
601
  <span className={`who mono ${r.reveals ? '' : 'done'}`}>
602
+ {t('gameView.recon.resolved', { who: r.who })}{r.reveals ? t('gameView.recon.reveals') : ''}
603
  </span>
604
  <span className="q">"{r.q}"</span>
605
  <span className="a">{r.a}</span>
 
608
  </ul>
609
  {budget > 0 ? (
610
  <div className="nh-recon__in">
611
+ <label>{t('gameView.recon.askLabel')}</label>
612
  <div className="sel">
613
  <select value={reconAgent} onChange={(e) => setReconAgent(e.target.value)}>
614
  {((agents.length > 0
 
624
  <input
625
  value={reconQ}
626
  onChange={(e) => setReconQ(e.target.value)}
627
+ placeholder={t('gameView.recon.placeholder')}
628
  />
629
  </div>
630
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
631
+ <span className="help mono">{t('gameView.recon.cost')}</span>
632
  <button
633
  className="btn primary sm"
634
  disabled={!reconQ.trim() || reconBusy}
635
  onClick={askRecon}
636
+ >{reconBusy ? <><Spinner /> {t('gameView.recon.asking')}</> : t('gameView.recon.spend')}</button>
637
  </div>
638
  </div>
639
  ) : (
640
  <div className="nh-recon__in">
641
+ <span className="help mono">{t('gameView.recon.exhausted')}</span>
642
  </div>
643
  )}
644
  </Panel>
645
 
646
  {(agents.length > 0 || state?.agents) && (
647
+ <Panel tag={t('gameView.agents.tag')} title={t('gameView.agents.title')}>
648
  <ul className="nh-agents">
649
+ {(agents.length > 0 ? agents : state.agents).map((a: any) => {
650
+ const kindRaw = a.kind || 'advisor';
651
+ const kindLabel = kindRaw === 'rival'
652
+ ? t('gameView.agents.kind.rival')
653
+ : kindRaw === 'advisor'
654
+ ? t('gameView.agents.kind.advisor')
655
+ : kindRaw;
656
+ return (
657
+ <li key={a.id}>
658
+ <span className="code mono">{a.code || a.id.slice(0, 4).toUpperCase()}</span>
659
+ <div>
660
+ <div className="nm">{a.label || a.name || a.id}</div>
661
+ <div className="rl">{a.posture || a.role || a.kind}</div>
662
+ </div>
663
+ <div className="rt">
664
+ <span className={`kind ${a.kind === 'rival' ? 'rival' : ''}`}>{kindLabel}</span>
665
+ </div>
666
+ </li>
667
+ );
668
+ })}
669
  </ul>
670
  </Panel>
671
  )}
 
687
  {/* ACTION COMPOSER */}
688
  <section className={`nh-composer ${actionSent ? 'submitted' : ''}`}>
689
  <div className="nh-composer__hd">
690
+ <h2>{t('gameView.composer.title', { turn })}</h2>
691
  {actionSent ? (
692
+ <span className="hint">{t('gameView.composer.submitted')}</span>
693
  ) : (
694
  <div role="tablist" aria-label="Composer mode" style={{ display: 'flex', gap: 6 }}>
695
  <button
 
698
  className={`btn sm ${composerMode === 'coach' ? 'primary' : 'ghost'}`}
699
  onClick={() => setComposerMode('coach')}
700
  disabled={submitBusy || coachBusy}
701
+ >{t('gameView.composer.tab.coach')}</button>
702
  <button
703
  role="tab"
704
  aria-selected={composerMode === 'manual'}
705
  className={`btn sm ${composerMode === 'manual' ? 'primary' : 'ghost'}`}
706
  onClick={() => setComposerMode('manual')}
707
  disabled={submitBusy || coachBusy}
708
+ >{t('gameView.composer.tab.manual')}</button>
709
  </div>
710
  )}
711
  </div>
712
 
713
  {actionSent ? (
714
  <>
715
+ <p className="fg-2">{t('gameView.composer.afterSubmit', { turn })}</p>
 
 
 
716
  <div className="nh-submit">
717
+ <span className="hint mono fg-3">{t('gameView.composer.advanceWarning')}</span>
718
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
719
  {state?.status === 'paused' ? (
720
  <button
 
722
  onClick={resumeAdvance}
723
  disabled={resumeBusy}
724
  >
725
+ {resumeBusy
726
+ ? <><Spinner /> {t('gameView.composer.resuming')}</>
727
+ : t('gameView.composer.resume')}
728
  </button>
729
  ) : (
730
  <>
 
733
  className="btn ghost sm"
734
  onClick={pauseAdvance}
735
  disabled={pauseBusy}
736
+ title={t('gameView.composer.pause.title')}
737
  >
738
+ {pauseBusy
739
+ ? <><Spinner /> {t('gameView.composer.pausing')}</>
740
+ : t('gameView.composer.pause')}
741
  </button>
742
  )}
743
  <button className="btn primary" onClick={advance} disabled={advanceBusy}>
744
+ {advanceBusy
745
+ ? <><Spinner /> {t('gameView.composer.resolving')}</>
746
+ : t('gameView.composer.advance')}
747
  </button>
748
  </>
749
  )}
 
787
  ) : (
788
  <>
789
  <div className="nh-formrow">
790
+ <label>
791
+ {t('gameView.form.intent.label')}{' '}
792
+ <span className="ct mono">{t('gameView.form.charCount', { count: intent.length, max: 160 })}</span>
793
+ </label>
794
  <input
795
  value={intent} maxLength={160}
796
  onChange={(e) => setIntent(e.target.value)}
797
+ placeholder={t('gameView.form.intent.placeholder')}
798
  />
799
  </div>
800
  <div className="nh-formrow">
801
+ <label>
802
+ {t('gameView.form.why.label')}{' '}
803
+ <span className="ct mono">{t('gameView.form.charCount', { count: why.length, max: 400 })}</span>
804
+ </label>
805
  <textarea
806
  value={why} maxLength={400}
807
  onChange={(e) => setWhy(e.target.value)}
808
+ placeholder={t('gameView.form.why.placeholder')}
809
  />
810
  </div>
811
 
812
  <div className="nh-anchors">
813
  <div className="legend">
814
+ <span>{t('gameView.form.anchors.legend')}</span>
815
+ <span className={`count ${anchorsFilled >= 3 ? 'ok' : ''}`}>
816
+ {t('gameView.form.anchors.count', { filled: anchorsFilled })}
817
+ </span>
818
  </div>
819
  {anchors.map((a, i) => (
820
  <div className="nh-anchor editable" key={i}>
 
831
  setAnchors(v);
832
  }}
833
  >
834
+ <option value="prior_analogue">{t('gameView.form.anchors.priorAnalogue')}</option>
835
+ <option value="stated_capability">{t('gameView.form.anchors.statedCapability')}</option>
836
+ <option value="resource_committed">{t('gameView.form.anchors.resourceCommitted')}</option>
837
  </select>
838
+ <div className="hint">{t(ANCHOR_HINT_KEYS[a.kind])}</div>
839
  </div>
840
  <input
841
  value={a.detail}
 
844
  v[i] = { ...a, detail: e.target.value };
845
  setAnchors(v);
846
  }}
847
+ placeholder={t(ANCHOR_PLACEHOLDER_KEYS[a.kind])}
848
  />
849
  <button
850
  className="rm"
851
  onClick={() => setAnchors(anchors.filter((_, j) => j !== i))}
852
  disabled={anchors.length <= 3}
853
+ >{t('gameView.form.anchors.remove')}</button>
854
  </div>
855
  ))}
856
  {anchors.length < 5 && (
 
858
  <button
859
  className="btn ghost sm"
860
  onClick={() => setAnchors([...anchors, { kind: 'resource_committed', detail: '' }])}
861
+ >{t('gameView.form.anchors.add')}</button>
862
  </div>
863
  )}
864
  </div>
 
873
  <div className="nh-submit">
874
  <span className="hint mono fg-3">
875
  {violations.length > 0
876
+ ? t('gameView.form.submit.violationsBlock')
877
  : preflightBusy
878
+ ? t('gameView.form.submit.preflight')
879
+ : t('gameView.form.submit.helper', { turn })}
880
  </span>
881
  <button
882
  className="btn primary"
 
884
  onClick={submitAction}
885
  >
886
  {submitBusy
887
+ ? <><Spinner /> {preflightBusy ? t('gameView.form.submit.checking') : t('gameView.form.submit.submitting')}</>
888
+ : t('gameView.form.submit.cta')}
889
  </button>
890
  </div>
891
  </>
 
914
  );
915
  }
916
 
917
+ const ANCHOR_HINT_KEYS: Record<AnchorKind, StringKey> = {
918
+ prior_analogue: 'gameView.form.anchors.hint.priorAnalogue',
919
+ stated_capability: 'gameView.form.anchors.hint.statedCapability',
920
+ resource_committed: 'gameView.form.anchors.hint.resourceCommitted',
921
  };
922
+ const ANCHOR_PLACEHOLDER_KEYS: Record<AnchorKind, StringKey> = {
923
+ prior_analogue: 'gameView.form.anchors.placeholder.priorAnalogue',
924
+ stated_capability: 'gameView.form.anchors.placeholder.statedCapability',
925
+ resource_committed: 'gameView.form.anchors.placeholder.resourceCommitted',
926
  };
927
 
928
  const DEFAULT_AGENTS = [
 
974
  forkBusy: number | null;
975
  onFork: (fromTurn: number) => void;
976
  }) {
977
+ const { t } = useI18n();
978
  const resolved = turns.filter(
979
+ (turn) => (turn?.verdicts?.length || 0) > 0 || !!turn?.player_action,
980
  );
981
  if (!resolved.length) return null;
982
  return (
983
  <section className="nh-verdicts">
984
+ <h2>{t('gameView.verdicts.title')}</h2>
985
+ {resolved.map((turn, i) => {
986
+ const action = turn.player_action;
987
+ const verdictCount = turn?.verdicts?.length || 0;
988
  return (
989
  <div className="nh-vturn" key={i}>
990
  <div className="nh-vturn__hd">
991
+ <span className="mono eyebrow">{t('gameView.verdicts.turn', { turn: turn.turn })}</span>
992
  <span className="mono fg-3">
993
+ {verdictCount > 0
994
+ ? t('gameView.verdicts.resolved', { count: verdictCount })
995
+ : t('gameView.verdicts.actionOnly')}
996
  </span>
997
  <span className="spacer" style={{ flex: 1 }} />
998
  <button
999
  className="btn ghost sm"
1000
  disabled={forkBusy !== null}
1001
+ onClick={() => onFork(turn.turn)}
1002
+ title={t('gameView.verdicts.fork.title', { turn: turn.turn })}
1003
  >
1004
+ {forkBusy === turn.turn
1005
+ ? <><Spinner /> {t('gameView.verdicts.forking')}</>
1006
+ : t('gameView.verdicts.fork', { turn: turn.turn })}
1007
  </button>
1008
  </div>
1009
  {action && (
1010
  <div className="nh-v">
1011
  <div className="nh-v__hd">
1012
+ <span className="actor mono">{t('gameView.verdicts.playerAction')}</span>
1013
  </div>
1014
  <p style={{ whiteSpace: 'pre-wrap' }}>
1015
  <strong>{action.intent}</strong>
 
1035
  onAdopt: (branch: ScenarioMap['branches'][number]) => void;
1036
  canRun: boolean;
1037
  }) {
1038
+ const { t } = useI18n();
1039
  const running = map?.status === 'running' || map?.status === 'pending';
1040
  const failed = map?.status === 'failed';
1041
  const complete = map?.status === 'complete';
 
1056
  <section className="nh-orch">
1057
  <div className="nh-orch__hd">
1058
  <div>
1059
+ <span className="eyebrow">{t('gameView.orch.eyebrow')}</span>
1060
  <h2 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
1061
  {complete
1062
+ ? t('gameView.orch.title.complete')
1063
  : running
1064
+ ? t('gameView.orch.title.running')
1065
+ : t('gameView.orch.title.idle')}
1066
  </h2>
1067
  </div>
1068
  {(complete || failed) && (
1069
  <button className="btn ghost sm" onClick={onToggle}>
1070
+ {open ? t('gameView.orch.hide') : t('gameView.orch.show')}
1071
  </button>
1072
  )}
1073
  </div>
 
1078
  {!map && !error && (
1079
  <div className="nh-orch__idle">
1080
  <p className="fg-2" style={{ margin: 0 }}>
1081
+ {t('gameView.orch.idleDek')}
 
1082
  </p>
1083
  {canRun ? (
1084
  <button className="btn primary" onClick={onRun}>
1085
+ {t('gameView.orch.run')}
1086
  </button>
1087
  ) : (
1088
+ <span className="help mono fg-3">{t('gameView.orch.locked')}</span>
 
 
1089
  )}
1090
  </div>
1091
  )}
 
1097
  <Spinner />
1098
  <div>
1099
  <div className="mono">
1100
+ {t('gameView.orch.progress', { done: map?.branches_complete ?? 0, total })}
 
 
 
1101
  </div>
1102
+ <div className="help mono fg-3">{t('gameView.orch.progressDek')}</div>
1103
  </div>
1104
  </div>
1105
  {vectors.length > 0 && (
 
1114
  <div className="nh-orch__vhd">
1115
  <span className="code mono">{v.id.slice(0, 4).toUpperCase()}</span>
1116
  <span className="mono fg-3" style={{ fontSize: 10.5 }}>
1117
+ {done ? t('gameView.orch.vector.complete') : t('gameView.orch.vector.running')}
1118
  </span>
1119
  </div>
1120
  <div className="nh-orch__vlabel">{v.label}</div>
 
1125
  )}
1126
  {vectors.length === 0 && (
1127
  <div className="help fg-3 mono" style={{ marginTop: 8 }}>
1128
+ {t('gameView.orch.generatingVectors')}
1129
  </div>
1130
  )}
1131
  </div>
 
1134
  {/* FAILED */}
1135
  {failed && open && (
1136
  <div className="nh-orch__failed">
1137
+ <p style={{ color: 'var(--oxblood)' }}>
1138
+ {t('gameView.orch.failed', { error: map?.error || t('gameView.orch.failed.unknown') })}
1139
+ </p>
1140
  {canRun ? (
1141
+ <button className="btn ghost sm" onClick={onRun}>{t('gameView.orch.retry')}</button>
1142
  ) : (
1143
+ <span className="help mono fg-3">{t('gameView.orch.locked')}</span>
 
 
1144
  )}
1145
  </div>
1146
  )}
 
1166
  <p className="nh-orch__cdesc">{v.description}</p>
1167
  {skipped ? (
1168
  <div className="nh-orch__cskip">
1169
+ <span className="eyebrow">{t('gameView.orch.skipped.eyebrow')}</span>
1170
+ <div className="help">{b?.skip_reason || t('gameView.orch.skipped.fallback')}</div>
1171
  </div>
1172
  ) : (
1173
  <>
 
1186
  )}
1187
  {action?.intent && (
1188
  <div className="nh-orch__cplay">
1189
+ <span className="eyebrow">{t('gameView.orch.played')}</span>
1190
  <div>{action.intent}</div>
1191
  </div>
1192
  )}
 
1196
  onClick={() => b && onAdopt(b)}
1197
  style={{ marginTop: 'auto' }}
1198
  >
1199
+ {t('gameView.orch.adopt')}
1200
  </button>
1201
  )}
1202
  </>
 
1207
  </div>
1208
  {map?.synthesis_markdown && (
1209
  <details className="nh-orch__full">
1210
+ <summary>
1211
+ {t('gameView.orch.fullSynthesis', {
1212
+ chars: map.synthesis_markdown.length.toLocaleString(),
1213
+ })}
1214
+ </summary>
1215
  <div className="setup-md" style={{ fontSize: 14, lineHeight: 1.6, marginTop: 10 }}>
1216
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{map.synthesis_markdown}</ReactMarkdown>
1217
  </div>
 
1219
  )}
1220
  <div className="nh-orch__ft">
1221
  {canRun ? (
1222
+ <button className="btn ghost sm" onClick={onRun}>{t('gameView.orch.rerun')}</button>
1223
  ) : (
1224
+ <span className="help mono fg-3">{t('gameView.orch.locked')}</span>
 
 
1225
  )}
1226
  </div>
1227
  </>
 
1272
  onSwitchToManualWithDraft: () => void;
1273
  turn: number;
1274
  }) {
1275
+ const { t } = useI18n();
1276
  const hasSession = session !== null;
1277
  const ready = session?.status === 'ready';
1278
  const draft = session?.synthesized_draft ?? null;
 
1284
  // Intro — collect initial prose before kicking off the coach.
1285
  <div className="nh-chat__input">
1286
  <p className="fg-2" style={{ marginTop: 0, marginBottom: 12 }}>
1287
+ {t('gameView.coach.intro')}
 
 
1288
  </p>
1289
  <form onSubmit={(e) => { e.preventDefault(); onStart(); }}>
1290
  <textarea
1291
  value={input}
1292
  rows={5}
1293
  maxLength={4000}
1294
+ placeholder={t('gameView.coach.placeholder')}
1295
  onChange={(e) => setInput(e.target.value)}
1296
  onKeyDown={(e) => {
1297
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
 
1301
  disabled={busy}
1302
  />
1303
  <div className="nh-chat__submit">
1304
+ <span className="hint">{t('gameView.coach.charHint', { count: input.length })}</span>
1305
  <button
1306
  type="submit"
1307
  className="btn primary"
1308
  disabled={!input.trim() || busy}
1309
  >
1310
+ {busy
1311
+ ? <><Spinner /> {t('gameView.coach.starting')}</>
1312
+ : t('gameView.coach.start')}
1313
  </button>
1314
  </div>
1315
  </form>
 
1323
  ))}
1324
  {busy && (
1325
  <div className="nh-msg control">
1326
+ <div className="nh-msg__meta">
1327
+ <span>{t('gameView.coach.busy.label')}</span> {t('gameView.coach.busy.suffix')}
1328
+ </div>
1329
  <div className="nh-msg__body"><Spinner /></div>
1330
  </div>
1331
  )}
 
1335
  {draft && (
1336
  <div className="nh-spec">
1337
  <span className="eyebrow">
1338
+ {ready
1339
+ ? t('gameView.coach.draft.eyebrowReady')
1340
+ : t('gameView.coach.draft.eyebrowProgress', { rounds: session?.rounds_used ?? 0 })}
1341
  </span>
1342
  <CoachDraftCard draft={draft} />
1343
  <div className="nh-spec__fn" style={{ marginTop: 6 }}>
1344
+ {t('gameView.coach.draft.footnote', { turn })}
1345
  </div>
1346
  </div>
1347
  )}
 
1349
  {ready ? (
1350
  <div className="nh-chat__approve">
1351
  <div>
1352
+ <span className="eyebrow">{t('gameView.coach.approve.eyebrow')}</span>
1353
+ <div className="hd">{t('gameView.coach.approve.title', { turn })}</div>
1354
  </div>
1355
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
1356
  <button className="btn ghost sm" onClick={onSwitchToManualWithDraft} disabled={busy}>
1357
+ {t('gameView.coach.approve.editManual')}
1358
  </button>
1359
  <button className="btn ghost sm" onClick={onReset} disabled={busy}>
1360
+ {t('gameView.coach.approve.restart')}
1361
  </button>
1362
  <button className="btn primary" onClick={onCommit} disabled={busy}>
1363
+ {busy
1364
+ ? <><Spinner /> {t('gameView.coach.approve.submitting')}</>
1365
+ : t('gameView.coach.approve.submit')}
1366
  </button>
1367
  </div>
1368
  </div>
 
1373
  value={input}
1374
  rows={4}
1375
  maxLength={4000}
1376
+ placeholder={t('gameView.coach.replyPlaceholder')}
1377
  onChange={(e) => setInput(e.target.value)}
1378
  onKeyDown={(e) => {
1379
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
 
1384
  />
1385
  <div className="nh-chat__submit">
1386
  <span className="hint">
1387
+ {t('gameView.coach.replyHint', {
1388
+ count: input.length,
1389
+ round: (session?.rounds_used ?? 0) + 1,
1390
+ })}
1391
  </span>
1392
  <button
1393
  type="submit"
1394
  className="btn primary"
1395
  disabled={!input.trim() || busy}
1396
  >
1397
+ {busy
1398
+ ? <><Spinner /> {t('gameView.coach.sending')}</>
1399
+ : t('gameView.coach.send')}
1400
  </button>
1401
  </div>
1402
  </form>
1403
  <div style={{ marginTop: 8 }}>
1404
  <button className="btn ghost sm" onClick={onReset} disabled={busy}>
1405
+ {t('gameView.coach.restart.cta')}
1406
  </button>
1407
  </div>
1408
  </div>
 
1416
  }
1417
 
1418
  function CoachMsg({ m }: { m: CoachMessage }) {
1419
+ const { t } = useI18n();
1420
  const who = m.role === 'user' ? 'user' : 'control';
1421
+ const label = m.role === 'user' ? t('gameView.coach.label.user') : t('gameView.coach.label.coach');
1422
  return (
1423
  <div className={`nh-msg ${who}`}>
1424
  <div className="nh-msg__meta"><span>{label}</span> {fmtCoachAt(m.at)}</div>
 
1436
  }
1437
 
1438
  function CoachDraftCard({ draft }: { draft: NonNullable<ActionCoachSession['synthesized_draft']> }) {
1439
+ const { t } = useI18n();
1440
  return (
1441
  <div style={{ display: 'grid', gap: 10, fontSize: 14, lineHeight: 1.55 }}>
1442
  <div>
1443
+ <div className="eyebrow" style={{ marginBottom: 2 }}>{t('gameView.coach.draft.intent')}</div>
1444
  <div style={{ fontWeight: 600 }}>{draft.intent}</div>
1445
  </div>
1446
  <div>
1447
+ <div className="eyebrow" style={{ marginBottom: 2 }}>{t('gameView.coach.draft.why')}</div>
1448
  <div>{draft.justification}</div>
1449
  </div>
1450
  <div>
1451
+ <div className="eyebrow" style={{ marginBottom: 4 }}>
1452
+ {t('gameView.coach.draft.anchorsLabel', { count: draft.anchors.length })}
1453
+ </div>
1454
  <ul style={{ margin: 0, paddingLeft: 18 }}>
1455
  {draft.anchors.map((a, i) => (
1456
  <li key={i} style={{ marginBottom: 4 }}>
 
1483
  onAdd: (input: { description: string; rationale: string; test_question: string; severity: ConstraintSeverity }) => Promise<void>;
1484
  onDelete: (constraint: HardConstraint) => void | Promise<void>;
1485
  }) {
1486
+ const { t } = useI18n();
1487
  const [adding, setAdding] = useState(false);
1488
  const [draft, setDraft] = useState({
1489
  description: '',
 
1529
  aria-expanded={open}
1530
  >
1531
  <div>
1532
+ <span className="eyebrow">{t('gameView.constraints.eyebrow')}</span>
1533
  <div className="hd">
1534
  {constraints.length === 0
1535
+ ? t('gameView.constraints.empty')
1536
+ : softCount > 0
1537
+ ? t('gameView.constraints.summaryWithSoft', { hard: hardCount, soft: softCount })
1538
+ : t('gameView.constraints.summary', { hard: hardCount })}
1539
  </div>
1540
  </div>
1541
+ <span className="mono fg-3">
1542
+ {open ? t('gameView.constraints.hide') : t('gameView.constraints.show')}
1543
+ </span>
1544
  </button>
1545
 
1546
  {open && (
 
1548
  {err && <p className="nh-constraints__err">{err}</p>}
1549
  {constraints.length === 0 ? (
1550
  <p className="fg-3" style={{ margin: '0 0 12px' }}>
1551
+ {t('gameView.constraints.emptyHint')}
 
1552
  </p>
1553
  ) : (
1554
  <ul className="nh-constraint-list">
 
1561
  {adding ? (
1562
  <div className="nh-constraint-add">
1563
  <div className="nh-formrow">
1564
+ <label>
1565
+ {t('gameView.constraints.draft.description')}{' '}
1566
+ <span className="ct mono">{t('gameView.form.charCount', { count: draft.description.length, max: 400 })}</span>
1567
+ </label>
1568
  <textarea
1569
  value={draft.description}
1570
  maxLength={400}
1571
  rows={2}
1572
  onChange={(e) => setDraft({ ...draft, description: e.target.value })}
1573
+ placeholder={t('gameView.constraints.draft.descriptionPlaceholder')}
1574
  />
1575
  </div>
1576
  <div className="nh-formrow">
1577
+ <label>
1578
+ {t('gameView.constraints.draft.rationale')}{' '}
1579
+ <span className="ct mono">{t('gameView.form.charCount', { count: draft.rationale.length, max: 400 })}</span>
1580
+ </label>
1581
  <textarea
1582
  value={draft.rationale}
1583
  maxLength={400}
1584
  rows={2}
1585
  onChange={(e) => setDraft({ ...draft, rationale: e.target.value })}
1586
+ placeholder={t('gameView.constraints.draft.rationalePlaceholder')}
1587
  />
1588
  </div>
1589
  <div className="nh-formrow">
1590
+ <label>
1591
+ {t('gameView.constraints.draft.testQuestion')}{' '}
1592
+ <span className="ct mono">{t('gameView.form.charCount', { count: draft.test_question.length, max: 300 })}</span>
1593
+ </label>
1594
  <textarea
1595
  value={draft.test_question}
1596
  maxLength={300}
1597
  rows={2}
1598
  onChange={(e) => setDraft({ ...draft, test_question: e.target.value })}
1599
+ placeholder={t('gameView.constraints.draft.testQuestionPlaceholder')}
1600
  />
1601
  </div>
1602
  <div className="nh-formrow">
1603
+ <label>{t('gameView.constraints.draft.severity')}</label>
1604
  <select
1605
  value={draft.severity}
1606
  onChange={(e) => setDraft({ ...draft, severity: e.target.value as ConstraintSeverity })}
1607
  style={{ font: 'inherit' }}
1608
  >
1609
+ <option value="hard">{t('gameView.constraints.draft.severity.hard')}</option>
1610
+ <option value="soft">{t('gameView.constraints.draft.severity.soft')}</option>
1611
  </select>
1612
  </div>
1613
  {localErr && <p className="nh-constraints__err">{localErr}</p>}
 
1617
  className="btn ghost sm"
1618
  onClick={() => { setAdding(false); setLocalErr(null); }}
1619
  disabled={busy}
1620
+ >{t('gameView.constraints.draft.cancel')}</button>
1621
  <button
1622
  type="button"
1623
  className="btn primary"
1624
  onClick={submit}
1625
  disabled={!draftValid || busy}
1626
  >
1627
+ {busy
1628
+ ? <><Spinner /> {t('gameView.constraints.draft.adding')}</>
1629
+ : t('gameView.constraints.draft.add')}
1630
  </button>
1631
  </div>
1632
  </div>
1633
  ) : (
1634
  <div className="nh-constraints__addbar">
1635
  <span className="hint mono fg-3">
1636
+ {t('gameView.constraints.footnote')}
1637
  </span>
1638
  <button type="button" className="btn ghost sm" onClick={() => setAdding(true)}>
1639
+ {t('gameView.constraints.add.cta')}
1640
  </button>
1641
  </div>
1642
  )}
 
1652
  c: HardConstraint;
1653
  onDelete: () => void | Promise<void>;
1654
  }) {
1655
+ const { t } = useI18n();
1656
  const [expanded, setExpanded] = useState(false);
1657
  const long = c.description.length > 120;
1658
  const display = expanded || !long ? c.description : `${c.description.slice(0, 117)}…`;
 
1665
  type="button"
1666
  className="nh-constraint__desc"
1667
  onClick={() => long && setExpanded((v) => !v)}
1668
+ title={long
1669
+ ? (expanded ? t('gameView.constraints.row.collapse') : t('gameView.constraints.row.expand'))
1670
+ : c.description}
1671
  >
1672
  {display}
1673
  </button>
 
1675
  type="button"
1676
  className="nh-constraint__rm"
1677
  onClick={onDelete}
1678
+ title={t('gameView.constraints.row.removeTitle')}
1679
  >
1680
+ {t('gameView.constraints.row.remove')}
1681
  </button>
1682
  </div>
1683
  {expanded && (
1684
  <div className="nh-constraint__detail">
1685
+ <div><span className="eyebrow">{t('gameView.constraints.row.rationale')}</span> {c.rationale}</div>
1686
+ <div>
1687
+ <span className="eyebrow">{t('gameView.constraints.row.testQuestion')}</span>{' '}
1688
+ <span className="mono">{c.test_question}</span>
1689
+ </div>
1690
  {c.source_ref && (
1691
+ <div>
1692
+ <span className="eyebrow">{t('gameView.constraints.row.sourceRef')}</span>{' '}
1693
+ <span className="mono">{c.source_ref}</span>
1694
+ </div>
1695
  )}
1696
  </div>
1697
  )}
 
1705
  violations: ConstraintViolation[];
1706
  onDismiss: () => void;
1707
  }) {
1708
+ const { t } = useI18n();
1709
  return (
1710
  <div className="nh-violations" role="alert">
1711
  <div className="nh-violations__hd">
1712
+ <span className="eyebrow">{t('gameView.violations.eyebrow')}</span>
1713
+ <button type="button" className="nh-violations__rm" onClick={onDismiss}>
1714
+ {t('gameView.violations.dismiss')}
1715
+ </button>
1716
  </div>
1717
  <ul>
1718
  {violations.map((v, i) => (
 
1723
  ))}
1724
  </ul>
1725
  <p className="hint mono fg-3" style={{ marginTop: 6 }}>
1726
+ {t('gameView.violations.tail')}
1727
  </p>
1728
  </div>
1729
  );
frontend/src/screens/GamesList.tsx CHANGED
@@ -1,8 +1,10 @@
1
  import { useEffect, useState } from 'react';
2
  import { api, GameSummary, Scenario } from '../api';
3
  import { navigate } from '../App';
 
4
 
5
  export function GamesList() {
 
6
  const [games, setGames] = useState<GameSummary[] | null>(null);
7
  const [scenarios, setScenarios] = useState<Record<string, Scenario>>({});
8
  const [err, setErr] = useState<string | null>(null);
@@ -19,25 +21,28 @@ export function GamesList() {
19
  return (
20
  <>
21
  <section className="nh-hero">
22
- <div className="nh-eyebrow"><b>Cases</b> · active & closed engagements</div>
23
- <h1>Every case you've war-gamed, with the receipts.</h1>
24
- <p className="nh-dek">
25
- Open a live case to continue deliberation, or review a closed debrief for its verdict chain and post-mortem.
26
- </p>
27
  </section>
28
 
29
  <div className="nh-tablehd">
30
- <span>Docket</span>
31
  <div className="nh-tablehd__r">
32
- <button className="btn ghost sm" onClick={() => navigate('/new')}>+ New case</button>
 
 
33
  </div>
34
  </div>
35
 
36
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
37
- {!games && !err && <p className="fg-3" style={{ padding: 20 }}>Loading cases…</p>}
38
  {games && games.length === 0 && (
39
  <div className="nh-dossier">
40
- <p>No cases yet. <a href="#/new">Open a new case</a> to generate a Turn 1 briefing.</p>
 
 
 
41
  </div>
42
  )}
43
 
@@ -45,18 +50,21 @@ export function GamesList() {
45
  <table className="nh-games">
46
  <thead>
47
  <tr>
48
- <th>Case</th>
49
- <th>Scenario</th>
50
- <th>Turn</th>
51
- <th>Status</th>
52
- <th>Model</th>
53
- <th className="rt">Updated</th>
54
  </tr>
55
  </thead>
56
  <tbody>
57
  {games.map((g) => {
58
  const sc = scenarios[g.scenario_id];
59
  const href = g.status === 'complete' ? `#/games/${g.id}/debrief` : `#/games/${g.id}`;
 
 
 
60
  return (
61
  <tr key={g.id} onClick={() => (window.location.hash = href)} style={{ cursor: 'pointer' }}>
62
  <td>
@@ -64,11 +72,11 @@ export function GamesList() {
64
  <div className="sub mono">{g.id.slice(0, 8)}…</div>
65
  </td>
66
  <td>
67
- {sc ? `${sc.industry} · vs ${sc.rival_name}` : g.scenario_id}
68
  </td>
69
  <td className="mono">{g.turn} / {g.max_turns}</td>
70
  <td>
71
- <span className={`nh-chip ${g.status}`}>{g.status}</span>
72
  </td>
73
  <td className="mono fg-3">{g.llm_model || '—'}</td>
74
  <td className="rt mono fg-3">{new Date(g.updated_at).toLocaleDateString()}</td>
 
1
  import { useEffect, useState } from 'react';
2
  import { api, GameSummary, Scenario } from '../api';
3
  import { navigate } from '../App';
4
+ import { useI18n } from '../i18n';
5
 
6
  export function GamesList() {
7
+ const { t } = useI18n();
8
  const [games, setGames] = useState<GameSummary[] | null>(null);
9
  const [scenarios, setScenarios] = useState<Record<string, Scenario>>({});
10
  const [err, setErr] = useState<string | null>(null);
 
21
  return (
22
  <>
23
  <section className="nh-hero">
24
+ <div className="nh-eyebrow"><b>{t('gamesList.eyebrow.lead')}</b> · {t('gamesList.eyebrow.note')}</div>
25
+ <h1>{t('gamesList.title')}</h1>
26
+ <p className="nh-dek">{t('gamesList.dek')}</p>
 
 
27
  </section>
28
 
29
  <div className="nh-tablehd">
30
+ <span>{t('gamesList.docket')}</span>
31
  <div className="nh-tablehd__r">
32
+ <button className="btn ghost sm" onClick={() => navigate('/new')}>
33
+ {t('gamesList.newCase')}
34
+ </button>
35
  </div>
36
  </div>
37
 
38
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
39
+ {!games && !err && <p className="fg-3" style={{ padding: 20 }}>{t('gamesList.loading')}</p>}
40
  {games && games.length === 0 && (
41
  <div className="nh-dossier">
42
+ <p>
43
+ {t('gamesList.empty.lead')} <a href="#/new">{t('gamesList.empty.cta')}</a>{' '}
44
+ {t('gamesList.empty.tail')}
45
+ </p>
46
  </div>
47
  )}
48
 
 
50
  <table className="nh-games">
51
  <thead>
52
  <tr>
53
+ <th>{t('gamesList.col.case')}</th>
54
+ <th>{t('gamesList.col.scenario')}</th>
55
+ <th>{t('gamesList.col.turn')}</th>
56
+ <th>{t('gamesList.col.status')}</th>
57
+ <th>{t('gamesList.col.model')}</th>
58
+ <th className="rt">{t('gamesList.col.updated')}</th>
59
  </tr>
60
  </thead>
61
  <tbody>
62
  {games.map((g) => {
63
  const sc = scenarios[g.scenario_id];
64
  const href = g.status === 'complete' ? `#/games/${g.id}/debrief` : `#/games/${g.id}`;
65
+ const statusLabel = g.status === 'complete'
66
+ ? t('gamesList.status.complete')
67
+ : t('gamesList.status.active');
68
  return (
69
  <tr key={g.id} onClick={() => (window.location.hash = href)} style={{ cursor: 'pointer' }}>
70
  <td>
 
72
  <div className="sub mono">{g.id.slice(0, 8)}…</div>
73
  </td>
74
  <td>
75
+ {sc ? t('gamesList.scenario.summary', { industry: sc.industry, rival: sc.rival_name }) : g.scenario_id}
76
  </td>
77
  <td className="mono">{g.turn} / {g.max_turns}</td>
78
  <td>
79
+ <span className={`nh-chip ${g.status}`}>{statusLabel}</span>
80
  </td>
81
  <td className="mono fg-3">{g.llm_model || '—'}</td>
82
  <td className="rt mono fg-3">{new Date(g.updated_at).toLocaleDateString()}</td>
frontend/src/screens/NewGame.tsx CHANGED
@@ -1,21 +1,23 @@
1
  import { useEffect, useState } from 'react';
2
  import { api, Scenario, ModelInfo } from '../api';
3
  import { navigate } from '../App';
 
4
 
5
  // Fallback role list when an older backend returns ModelsResponse without
6
  // `roles[]`. Mirrors `wargame.agents.roles.CANONICAL_ROLES`.
7
  const FALLBACK_ROLES = ['control', 'agent', 'rival', 'critic', 'premortem', 'evaluator'];
8
 
9
- const ROLE_BLURBS: Record<string, string> = {
10
- control: 'Briefings, adjudication, world updates',
11
- agent: 'Advisory voices in the room',
12
- rival: 'Rival CEO planner',
13
- critic: 'Critic agent',
14
- premortem: 'Pre-mortem agent',
15
- evaluator: 'Independent rubric scorer',
16
  };
17
 
18
  export function NewGame() {
 
19
  const [scenarios, setScenarios] = useState<Scenario[] | null>(null);
20
  const [picked, setPicked] = useState<string>('');
21
  const [models, setModels] = useState<ModelInfo[]>([]);
@@ -25,6 +27,7 @@ export function NewGame() {
25
  const [overridesOpen, setOverridesOpen] = useState(false);
26
  const [busy, setBusy] = useState(false);
27
  const [err, setErr] = useState<string | null>(null);
 
28
 
29
  useEffect(() => {
30
  api.listScenarios().then((r) => {
@@ -44,6 +47,10 @@ export function NewGame() {
44
  }).catch(() => { /* non-fatal — backend will use its default */ });
45
  }, []);
46
 
 
 
 
 
47
  function setRoleOverride(role: string, modelId: string) {
48
  setOverrides((prev) => {
49
  const next = { ...prev };
@@ -53,6 +60,11 @@ export function NewGame() {
53
  });
54
  }
55
 
 
 
 
 
 
56
  async function create() {
57
  setBusy(true); setErr(null);
58
  try {
@@ -60,36 +72,36 @@ export function NewGame() {
60
  picked,
61
  model || undefined,
62
  Object.keys(overrides).length > 0 ? overrides : undefined,
 
63
  );
64
  navigate(`/games/${r.game_id}`);
65
  } catch (e) { setErr(String(e)); setBusy(false); }
66
  }
67
 
 
 
 
 
 
68
  return (
69
  <>
70
  <section className="nh-hero">
71
- <div className="nh-eyebrow"><b>New case</b> · choose the scenario</div>
72
- <h1>Select a dossier. We'll generate Turn 1 from the ground up.</h1>
73
- <p className="nh-dek">
74
- Each scenario seeds a rival playbook, board agenda, agent roster, and fog-of-war intelligence briefing.
75
- Generation takes about thirty seconds.
76
- </p>
77
  </section>
78
 
79
  <div className="nh-create" style={{ marginBottom: 20 }}>
80
  <div>
81
- <span className="eyebrow">Or design your own</span>
82
- <div className="hd">Build a new case with the setup agent</div>
83
- <p className="fg-3">
84
- Describe your situation; the agent asks clarifying questions and scaffolds a fresh scenario —
85
- world bible, agent roster, hidden agendas.
86
- </p>
87
  </div>
88
- <a className="btn secondary" href="#/new-case">Talk to the setup agent →</a>
89
  </div>
90
 
91
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
92
- {!scenarios && !err && <p className="fg-3">Loading scenarios…</p>}
93
 
94
  {scenarios && (
95
  <div className="nh-scenarios">
@@ -104,37 +116,51 @@ export function NewGame() {
104
  <div className="nh-sc__meta">
105
  <span>{s.industry}</span>
106
  <span className="sep" />
107
- <span>vs {s.rival_name}</span>
108
  </div>
109
- <p>Role · {s.player_role} · {s.agent_count} advisory agents at your board.</p>
110
- <div className="nh-sc__fn">Five turns · fog-of-war briefings · adjudicated outcomes</div>
 
 
 
111
  </button>
112
  ))}
113
  </div>
114
  )}
115
 
 
 
 
 
 
 
 
 
 
 
 
116
  <div className="nh-create">
117
  <div>
118
- <span className="eyebrow">Ready</span>
119
- <div className="hd">{picked ? `Open ${scenarios?.find((s) => s.scenario_id === picked)?.company_name ?? picked}` : 'Pick a scenario above'}</div>
120
  <p className="fg-3">
121
  {models.length > 0 ? (
122
  <>
123
- Engine ·{' '}
124
  <select value={model} onChange={(e) => setModel(e.target.value)} style={{ font: 'inherit' }}>
125
  {models.map((m) => (
126
  <option key={m.id} value={m.id}>{m.display_name} · {m.provider}</option>
127
  ))}
128
  </select>
129
- {' '}· generates briefing · ~30s · Turn 1 of 5.
130
  </>
131
  ) : (
132
- <>Model generates briefing · ~30s · Turn 1 of 5.</>
133
  )}
134
  </p>
135
  </div>
136
  <button className="btn primary" onClick={create} disabled={!picked || busy}>
137
- {busy ? 'Generating briefing…' : 'Open case →'}
138
  </button>
139
  </div>
140
 
@@ -154,6 +180,40 @@ export function NewGame() {
154
  );
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  function RoleOverrides({
158
  roles, models, overrides, open, onToggle, onSet, onReset, gameDefault,
159
  }: {
@@ -166,7 +226,13 @@ function RoleOverrides({
166
  onReset: () => void;
167
  gameDefault: string;
168
  }) {
 
169
  const overrideCount = Object.keys(overrides).length;
 
 
 
 
 
170
  return (
171
  <section className="nh-role-overrides">
172
  <button
@@ -176,39 +242,36 @@ function RoleOverrides({
176
  aria-expanded={open}
177
  >
178
  <div>
179
- <span className="eyebrow">Per-role model routing</span>
180
- <div className="hd">
181
- {overrideCount === 0
182
- ? 'All roles use the game default'
183
- : `${overrideCount} role${overrideCount === 1 ? '' : 's'} routed to a different model`}
184
- </div>
185
  </div>
186
- <span className="mono fg-3">{open ? 'Hide ↑' : 'Configure ↓'}</span>
 
 
187
  </button>
188
  {open && (
189
  <div className="nh-role-overrides__body">
190
  <p className="fg-3" style={{ margin: '0 0 12px' }}>
191
- Each role can be pinned to a specific model — leave on the default to use{' '}
192
- <span className="mono">{gameDefault || '(server default)'}</span>. Useful for routing
193
- cheap planning to small models and reserving the flagship for adjudication.
194
  </p>
195
  <div className="nh-role-overrides__rows">
196
  {roles.map((role) => {
197
  const current = overrides[role] ?? '';
 
198
  return (
199
  <div className="nh-role-row" key={role}>
200
  <div className="nh-role-row__name">
201
  <span className="code mono">{role}</span>
202
- {ROLE_BLURBS[role] && (
203
- <span className="hint">{ROLE_BLURBS[role]}</span>
204
- )}
205
  </div>
206
  <select
207
  value={current}
208
  onChange={(e) => onSet(role, e.target.value)}
209
  style={{ font: 'inherit' }}
210
  >
211
- <option value="">(use game default)</option>
212
  {models.map((m) => (
213
  <option key={m.id} value={m.id}>
214
  {m.display_name} · {m.provider}
@@ -226,7 +289,7 @@ function RoleOverrides({
226
  onClick={onReset}
227
  disabled={overrideCount === 0}
228
  >
229
- Reset to defaults
230
  </button>
231
  </div>
232
  </div>
 
1
  import { useEffect, useState } from 'react';
2
  import { api, Scenario, ModelInfo } from '../api';
3
  import { navigate } from '../App';
4
+ import { useI18n, type StringKey, type Language } from '../i18n';
5
 
6
  // Fallback role list when an older backend returns ModelsResponse without
7
  // `roles[]`. Mirrors `wargame.agents.roles.CANONICAL_ROLES`.
8
  const FALLBACK_ROLES = ['control', 'agent', 'rival', 'critic', 'premortem', 'evaluator'];
9
 
10
+ const ROLE_BLURB_KEYS: Record<string, StringKey> = {
11
+ control: 'newGame.roleOverrides.role.control',
12
+ agent: 'newGame.roleOverrides.role.agent',
13
+ rival: 'newGame.roleOverrides.role.rival',
14
+ critic: 'newGame.roleOverrides.role.critic',
15
+ premortem: 'newGame.roleOverrides.role.premortem',
16
+ evaluator: 'newGame.roleOverrides.role.evaluator',
17
  };
18
 
19
  export function NewGame() {
20
+ const { t, lang, setLang } = useI18n();
21
  const [scenarios, setScenarios] = useState<Scenario[] | null>(null);
22
  const [picked, setPicked] = useState<string>('');
23
  const [models, setModels] = useState<ModelInfo[]>([]);
 
27
  const [overridesOpen, setOverridesOpen] = useState(false);
28
  const [busy, setBusy] = useState(false);
29
  const [err, setErr] = useState<string | null>(null);
30
+ const [language, setGameLanguage] = useState<Language>(lang);
31
 
32
  useEffect(() => {
33
  api.listScenarios().then((r) => {
 
47
  }).catch(() => { /* non-fatal — backend will use its default */ });
48
  }, []);
49
 
50
+ // Keep the local `language` state in sync with the global preference until
51
+ // the user explicitly clicks one of the pills (after which it pins).
52
+ useEffect(() => { setGameLanguage(lang); }, [lang]);
53
+
54
  function setRoleOverride(role: string, modelId: string) {
55
  setOverrides((prev) => {
56
  const next = { ...prev };
 
60
  });
61
  }
62
 
63
+ function pickLanguage(next: Language) {
64
+ setGameLanguage(next);
65
+ setLang(next);
66
+ }
67
+
68
  async function create() {
69
  setBusy(true); setErr(null);
70
  try {
 
72
  picked,
73
  model || undefined,
74
  Object.keys(overrides).length > 0 ? overrides : undefined,
75
+ language,
76
  );
77
  navigate(`/games/${r.game_id}`);
78
  } catch (e) { setErr(String(e)); setBusy(false); }
79
  }
80
 
81
+ const pickedScenario = scenarios?.find((s) => s.scenario_id === picked);
82
+ const readyTitle = pickedScenario
83
+ ? t('newGame.ready.openCompany', { company: pickedScenario.company_name })
84
+ : t('newGame.ready.pick');
85
+
86
  return (
87
  <>
88
  <section className="nh-hero">
89
+ <div className="nh-eyebrow"><b>{t('newGame.eyebrow.label')}</b> · {t('newGame.eyebrow.note')}</div>
90
+ <h1>{t('newGame.title')}</h1>
91
+ <p className="nh-dek">{t('newGame.dek')}</p>
 
 
 
92
  </section>
93
 
94
  <div className="nh-create" style={{ marginBottom: 20 }}>
95
  <div>
96
+ <span className="eyebrow">{t('newGame.designOwn.eyebrow')}</span>
97
+ <div className="hd">{t('newGame.designOwn.title')}</div>
98
+ <p className="fg-3">{t('newGame.designOwn.dek')}</p>
 
 
 
99
  </div>
100
+ <a className="btn secondary" href="#/new-case">{t('newGame.designOwn.cta')}</a>
101
  </div>
102
 
103
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
104
+ {!scenarios && !err && <p className="fg-3">{t('newGame.scenarios.loading')}</p>}
105
 
106
  {scenarios && (
107
  <div className="nh-scenarios">
 
116
  <div className="nh-sc__meta">
117
  <span>{s.industry}</span>
118
  <span className="sep" />
119
+ <span>{t('newGame.scenario.vs', { rival: s.rival_name })}</span>
120
  </div>
121
+ <p>
122
+ {t('newGame.scenario.role')} · {s.player_role} ·{' '}
123
+ {t('newGame.scenario.agentCount', { count: s.agent_count })}
124
+ </p>
125
+ <div className="nh-sc__fn">{t('newGame.scenario.footnote')}</div>
126
  </button>
127
  ))}
128
  </div>
129
  )}
130
 
131
+ <LanguagePicker
132
+ language={language}
133
+ onPick={pickLanguage}
134
+ labels={{
135
+ eyebrow: t('newGame.lang.eyebrow'),
136
+ helper: t('newGame.lang.helper'),
137
+ en: t('newGame.lang.en'),
138
+ de: t('newGame.lang.de'),
139
+ }}
140
+ />
141
+
142
  <div className="nh-create">
143
  <div>
144
+ <span className="eyebrow">{t('newGame.ready.eyebrow')}</span>
145
+ <div className="hd">{readyTitle}</div>
146
  <p className="fg-3">
147
  {models.length > 0 ? (
148
  <>
149
+ {t('newGame.ready.engineWith')}{' '}
150
  <select value={model} onChange={(e) => setModel(e.target.value)} style={{ font: 'inherit' }}>
151
  {models.map((m) => (
152
  <option key={m.id} value={m.id}>{m.display_name} · {m.provider}</option>
153
  ))}
154
  </select>
155
+ {' '}{t('newGame.ready.engineTail')}
156
  </>
157
  ) : (
158
+ <>{t('newGame.ready.modelOnly')}</>
159
  )}
160
  </p>
161
  </div>
162
  <button className="btn primary" onClick={create} disabled={!picked || busy}>
163
+ {busy ? t('newGame.ready.busy') : t('newGame.ready.openCta')}
164
  </button>
165
  </div>
166
 
 
180
  );
181
  }
182
 
183
+ function LanguagePicker({
184
+ language, onPick, labels,
185
+ }: {
186
+ language: Language;
187
+ onPick: (l: Language) => void;
188
+ labels: { eyebrow: string; helper: string; en: string; de: string };
189
+ }) {
190
+ const Pill = ({ value, children }: { value: Language; children: React.ReactNode }) => (
191
+ <button
192
+ type="button"
193
+ onClick={() => onPick(value)}
194
+ aria-pressed={language === value}
195
+ className={`btn sm ${language === value ? 'primary' : 'ghost'}`}
196
+ style={{ minWidth: 96 }}
197
+ >
198
+ {children}
199
+ </button>
200
+ );
201
+ return (
202
+ <section className="nh-lang-picker" style={{ marginBottom: 20 }}>
203
+ <div className="nh-create" style={{ alignItems: 'center' }}>
204
+ <div>
205
+ <span className="eyebrow">{labels.eyebrow}</span>
206
+ <p className="fg-3" style={{ margin: 0 }}>{labels.helper}</p>
207
+ </div>
208
+ <div role="group" aria-label={labels.eyebrow} style={{ display: 'flex', gap: 6 }}>
209
+ <Pill value="en">{labels.en}</Pill>
210
+ <Pill value="de">{labels.de}</Pill>
211
+ </div>
212
+ </div>
213
+ </section>
214
+ );
215
+ }
216
+
217
  function RoleOverrides({
218
  roles, models, overrides, open, onToggle, onSet, onReset, gameDefault,
219
  }: {
 
226
  onReset: () => void;
227
  gameDefault: string;
228
  }) {
229
+ const { t } = useI18n();
230
  const overrideCount = Object.keys(overrides).length;
231
+ const summary = overrideCount === 0
232
+ ? t('newGame.roleOverrides.allDefault')
233
+ : overrideCount === 1
234
+ ? t('newGame.roleOverrides.someRouted', { count: overrideCount })
235
+ : t('newGame.roleOverrides.someRouted.plural', { count: overrideCount });
236
  return (
237
  <section className="nh-role-overrides">
238
  <button
 
242
  aria-expanded={open}
243
  >
244
  <div>
245
+ <span className="eyebrow">{t('newGame.roleOverrides.eyebrow')}</span>
246
+ <div className="hd">{summary}</div>
 
 
 
 
247
  </div>
248
+ <span className="mono fg-3">
249
+ {open ? t('newGame.roleOverrides.hide') : t('newGame.roleOverrides.configure')}
250
+ </span>
251
  </button>
252
  {open && (
253
  <div className="nh-role-overrides__body">
254
  <p className="fg-3" style={{ margin: '0 0 12px' }}>
255
+ {t('newGame.roleOverrides.intro.lead')}{' '}
256
+ <span className="mono">{gameDefault || t('newGame.roleOverrides.serverDefault')}</span>.
257
+ {' '}{t('newGame.roleOverrides.intro.tail')}
258
  </p>
259
  <div className="nh-role-overrides__rows">
260
  {roles.map((role) => {
261
  const current = overrides[role] ?? '';
262
+ const blurbKey = ROLE_BLURB_KEYS[role];
263
  return (
264
  <div className="nh-role-row" key={role}>
265
  <div className="nh-role-row__name">
266
  <span className="code mono">{role}</span>
267
+ {blurbKey && <span className="hint">{t(blurbKey)}</span>}
 
 
268
  </div>
269
  <select
270
  value={current}
271
  onChange={(e) => onSet(role, e.target.value)}
272
  style={{ font: 'inherit' }}
273
  >
274
+ <option value="">{t('newGame.roleOverrides.useDefault')}</option>
275
  {models.map((m) => (
276
  <option key={m.id} value={m.id}>
277
  {m.display_name} · {m.provider}
 
289
  onClick={onReset}
290
  disabled={overrideCount === 0}
291
  >
292
+ {t('newGame.roleOverrides.reset')}
293
  </button>
294
  </div>
295
  </div>
frontend/src/screens/SetupCase.tsx CHANGED
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
4
  import { api, SetupSession, SetupMessage, UploadedDocument } from '../api';
5
  import { navigate } from '../App';
6
  import { Spinner } from '../components/Shell';
 
7
 
8
  type Phase = 'intro' | 'chat' | 'committing';
9
 
@@ -28,6 +29,7 @@ function isAllowedExt(name: string): boolean {
28
  }
29
 
30
  export function SetupCase() {
 
31
  const [phase, setPhase] = useState<Phase>('intro');
32
  const [scenarioId, setScenarioId] = useState(defaultScenarioId);
33
  const [brief, setBrief] = useState('');
@@ -92,24 +94,24 @@ export function SetupCase() {
92
  const list = Array.from(files);
93
  if (list.length === 0) return;
94
  if (docs.length + list.length > DOC_MAX_COUNT) {
95
- setDocErr(`Cap is ${DOC_MAX_COUNT} documents per session — currently ${docs.length} uploaded.`);
96
  return;
97
  }
98
  setDocBusy(true);
99
  try {
100
  for (const file of list) {
101
  if (!isAllowedExt(file.name)) {
102
- setDocErr(`"${file.name}": only ${DOC_ALLOWED_EXT.join(', ')} are accepted.`);
103
  continue;
104
  }
105
  if (file.size > DOC_MAX_BYTES) {
106
- setDocErr(`"${file.name}": exceeds the 25 MB cap (${fmtBytes(file.size)}).`);
107
  continue;
108
  }
109
  try {
110
  await api.uploadDocument(session.session_id, file);
111
  } catch (e: any) {
112
- setDocErr(`"${file.name}": ${e?.message ?? String(e)}`);
113
  }
114
  }
115
  await refreshDocs(session.session_id);
@@ -125,7 +127,7 @@ export function SetupCase() {
125
  await api.deleteDocument(session.session_id, filename);
126
  await refreshDocs(session.session_id);
127
  } catch (e: any) {
128
- setDocErr(`Could not remove "${filename}": ${e?.message ?? String(e)}`);
129
  }
130
  }
131
 
@@ -153,13 +155,9 @@ export function SetupCase() {
153
  return (
154
  <>
155
  <section className="nh-hero">
156
- <div className="nh-eyebrow"><b>New case</b> · design one with the setup agent</div>
157
- <h1>Describe the situation. The agent will ask clarifying questions, then scaffold the scenario.</h1>
158
- <p className="nh-dek">
159
- Write a paragraph or two about the company, the rival or threat, the stakes, and whatever constraints
160
- you already know. The setup agent will fill in the gaps: agent roster, world bible, starting metrics,
161
- hidden agendas. Takes a few rounds of Q&amp;A (30–60s per round).
162
- </p>
163
  </section>
164
 
165
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
@@ -170,44 +168,44 @@ export function SetupCase() {
170
  <form onSubmit={(e) => { e.preventDefault(); start(); }}>
171
  <div className="nh-formrow">
172
  <label>
173
- Case ID{' '}
174
- <span className="ct mono">{scenarioId.length} / 60</span>
175
  </label>
176
  <input
177
  value={scenarioId}
178
  maxLength={60}
179
  onChange={(e) => setScenarioId(e.target.value)}
180
- placeholder="e.g. nord_heatpump_takeover"
181
  style={{ fontFamily: 'var(--font-mono)' }}
182
  />
183
  <div className="hint" style={{ marginTop: 4, color: 'var(--slate)' }}>
184
- Lowercase letters, digits, underscores. Must start with a letter. 3–60 chars.
185
  {!scenarioIdValid && scenarioId.length > 0 && (
186
- <span style={{ color: 'var(--oxblood)' }}> Invalid format.</span>
187
  )}
188
  </div>
189
  </div>
190
  <div className="nh-formrow" style={{ marginTop: 14 }}>
191
  <label>
192
- Situation brief{' '}
193
- <span className="ct mono">{brief.trim().length} / 2000 (min 10)</span>
194
  </label>
195
  <textarea
196
  value={brief}
197
  maxLength={2000}
198
  rows={8}
199
  onChange={(e) => setBrief(e.target.value)}
200
- placeholder="e.g. We're a mid-sized German heat-pump maker, €680M revenue, 2800 employees. A Chinese entrant (Aergis Climate) just acquired an Italian subsidiary and is undercutting us on price at the EU commercial segment. Board wants a response for Q2. Regulatory pressure from F-gas phase-out plays in our favor on paper but the Betriebsrat is nervous about restructuring."
201
  />
202
  </div>
203
  <div className="nh-chat__submit">
204
- <span className="hint">The agent uses this as its starting context.</span>
205
  <button
206
  type="submit"
207
  className="btn primary"
208
  disabled={!scenarioIdValid || !briefValid || busy}
209
  >
210
- {busy ? <><Spinner /> Starting conversation…</> : 'Start conversation →'}
211
  </button>
212
  </div>
213
  </form>
@@ -226,14 +224,13 @@ export function SetupCase() {
226
  <>
227
  <section className="nh-hero">
228
  <div className="nh-eyebrow">
229
- <b>Setup agent</b> · case <span className="mono">{session?.scenario_id}</span>
 
230
  <span>·</span>
231
- <b>{ready ? 'Brief ready — review below' : 'Gathering context'}</b>
232
  </div>
233
  <h1>
234
- {ready
235
- ? 'The agent has a complete brief. Review and commit to scaffold the scenario.'
236
- : 'Answer the agent\u2019s questions. It\u2019ll ask a few more then hand you a synthesized brief.'}
237
  </h1>
238
  </section>
239
 
@@ -255,7 +252,9 @@ export function SetupCase() {
255
  {msgs.map((m, i) => <Msg key={i} m={m} />)}
256
  {busy && (
257
  <div className="nh-msg control">
258
- <div className="nh-msg__meta"><span>Setup agent</span> thinking…</div>
 
 
259
  <div className="nh-msg__body"><Spinner /></div>
260
  </div>
261
  )}
@@ -263,13 +262,14 @@ export function SetupCase() {
263
 
264
  {ready && session?.synthesized_brief && (
265
  <div className="nh-spec">
266
- <span className="eyebrow">Synthesized brief · this is what will be scaffolded</span>
267
  <div className="setup-md" style={{ fontSize: 14, lineHeight: 1.55 }}>
268
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{session.synthesized_brief}</ReactMarkdown>
269
  </div>
270
  <div className="nh-spec__fn">
271
- Commit writes <span className="mono">scenarios/{session.scenario_id}/</span> — world_bible,
272
- agents.yaml, initial_state.json — then opens Turn 1.
 
273
  </div>
274
  </div>
275
  )}
@@ -279,20 +279,24 @@ export function SetupCase() {
279
  <div className="nh-chat__creating">
280
  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
281
  <Spinner />
282
- <span>Scaffolding <span className="mono">{session?.scenario_id}</span> and generating Turn 1…</span>
 
 
 
 
283
  </div>
284
  <div className="hint mono fg-3" style={{ marginTop: 6 }}>
285
- This takes ~30–60s (scaffold + briefing). Don't navigate away.
286
  </div>
287
  </div>
288
  ) : (
289
  <div className="nh-chat__approve">
290
  <div>
291
- <span className="eyebrow">Approve to scaffold</span>
292
- <div className="hd">Commit brief & launch Turn 1</div>
293
  </div>
294
  <button className="btn primary" onClick={commitAndLaunch} disabled={busy}>
295
- Commit & launch →
296
  </button>
297
  </div>
298
  )
@@ -310,17 +314,17 @@ export function SetupCase() {
310
  send();
311
  }
312
  }}
313
- placeholder="Your answer… (Ctrl/Cmd+Enter to send)"
314
  disabled={busy}
315
  />
316
  <div className="nh-chat__submit">
317
- <span className="hint">{answer.length} / 4000</span>
318
  <button
319
  type="submit"
320
  className="btn primary"
321
  disabled={!answer.trim() || busy}
322
  >
323
- {busy ? <><Spinner /> Sending…</> : 'Send →'}
324
  </button>
325
  </div>
326
  </form>
@@ -333,11 +337,12 @@ export function SetupCase() {
333
  }
334
 
335
  function Msg({ m }: { m: SetupMessage }) {
 
336
  const who = m.role === 'user' ? 'user' : 'control';
337
- const label = m.role === 'user' ? 'You' : 'Setup agent';
338
  return (
339
  <div className={`nh-msg ${who}`}>
340
- <div className="nh-msg__meta"><span>{label}</span> {fmtAt(m.at)}</div>
341
  <div className="nh-msg__body">
342
  {m.role === 'setup_agent' ? (
343
  <div className="setup-md">
@@ -367,6 +372,7 @@ function DocUploadPanel({
367
  onUpload: (files: FileList | File[]) => void | Promise<void>;
368
  onRemove: (filename: string) => void | Promise<void>;
369
  }) {
 
370
  const [drag, setDrag] = useState(false);
371
  const fileInputRef = useRef<HTMLInputElement | null>(null);
372
  const remaining = DOC_MAX_COUNT - docs.length;
@@ -375,12 +381,10 @@ function DocUploadPanel({
375
  <section className="nh-doc-upload">
376
  <div className="nh-doc-upload__hd">
377
  <div>
378
- <span className="eyebrow">Grounding documents</span>
379
- <div className="hd">
380
- Drop in source materials — the setup agent extracts hard constraints from these at commit time.
381
- </div>
382
  </div>
383
- <span className="mono fg-3">{docs.length} / {DOC_MAX_COUNT}</span>
384
  </div>
385
 
386
  <div
@@ -425,13 +429,13 @@ function DocUploadPanel({
425
  />
426
  <div className="nh-doc-drop__cta">
427
  {busy ? (
428
- <><Spinner /> Uploading…</>
429
  ) : remaining <= 0 ? (
430
- <span>Limit reached — remove a document to upload another.</span>
431
  ) : (
432
  <>
433
- <strong>Drop documents here</strong> or click to browse —
434
- <span className="mono"> {DOC_ALLOWED_EXT.join(' · ')}</span> · max 25 MB each.
435
  </>
436
  )}
437
  </div>
@@ -451,7 +455,7 @@ function DocUploadPanel({
451
  onClick={() => onRemove(d.filename)}
452
  disabled={busy}
453
  >
454
- remove
455
  </button>
456
  </li>
457
  ))}
 
4
  import { api, SetupSession, SetupMessage, UploadedDocument } from '../api';
5
  import { navigate } from '../App';
6
  import { Spinner } from '../components/Shell';
7
+ import { useI18n, type StringKey } from '../i18n';
8
 
9
  type Phase = 'intro' | 'chat' | 'committing';
10
 
 
29
  }
30
 
31
  export function SetupCase() {
32
+ const { t } = useI18n();
33
  const [phase, setPhase] = useState<Phase>('intro');
34
  const [scenarioId, setScenarioId] = useState(defaultScenarioId);
35
  const [brief, setBrief] = useState('');
 
94
  const list = Array.from(files);
95
  if (list.length === 0) return;
96
  if (docs.length + list.length > DOC_MAX_COUNT) {
97
+ setDocErr(t('setup.docs.cap', { max: DOC_MAX_COUNT, count: docs.length }));
98
  return;
99
  }
100
  setDocBusy(true);
101
  try {
102
  for (const file of list) {
103
  if (!isAllowedExt(file.name)) {
104
+ setDocErr(t('setup.docs.unsupported', { file: file.name, exts: DOC_ALLOWED_EXT.join(', ') }));
105
  continue;
106
  }
107
  if (file.size > DOC_MAX_BYTES) {
108
+ setDocErr(t('setup.docs.tooLarge', { file: file.name, size: fmtBytes(file.size) }));
109
  continue;
110
  }
111
  try {
112
  await api.uploadDocument(session.session_id, file);
113
  } catch (e: any) {
114
+ setDocErr(t('setup.docs.uploadFail', { file: file.name, message: e?.message ?? String(e) }));
115
  }
116
  }
117
  await refreshDocs(session.session_id);
 
127
  await api.deleteDocument(session.session_id, filename);
128
  await refreshDocs(session.session_id);
129
  } catch (e: any) {
130
+ setDocErr(t('setup.docs.removeFail', { file: filename, message: e?.message ?? String(e) }));
131
  }
132
  }
133
 
 
155
  return (
156
  <>
157
  <section className="nh-hero">
158
+ <div className="nh-eyebrow"><b>{t('setup.eyebrow.lead')}</b> · {t('setup.eyebrow.tail')}</div>
159
+ <h1>{t('setup.intro.title')}</h1>
160
+ <p className="nh-dek">{t('setup.intro.dek')}</p>
 
 
 
 
161
  </section>
162
 
163
  {err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
 
168
  <form onSubmit={(e) => { e.preventDefault(); start(); }}>
169
  <div className="nh-formrow">
170
  <label>
171
+ {t('setup.caseId.label')}{' '}
172
+ <span className="ct mono">{t('setup.caseId.charCount', { count: scenarioId.length })}</span>
173
  </label>
174
  <input
175
  value={scenarioId}
176
  maxLength={60}
177
  onChange={(e) => setScenarioId(e.target.value)}
178
+ placeholder={t('setup.caseId.placeholder')}
179
  style={{ fontFamily: 'var(--font-mono)' }}
180
  />
181
  <div className="hint" style={{ marginTop: 4, color: 'var(--slate)' }}>
182
+ {t('setup.caseId.helper')}
183
  {!scenarioIdValid && scenarioId.length > 0 && (
184
+ <span style={{ color: 'var(--oxblood)' }}> {t('setup.caseId.invalid')}</span>
185
  )}
186
  </div>
187
  </div>
188
  <div className="nh-formrow" style={{ marginTop: 14 }}>
189
  <label>
190
+ {t('setup.brief.label')}{' '}
191
+ <span className="ct mono">{t('setup.brief.charCount', { count: brief.trim().length })}</span>
192
  </label>
193
  <textarea
194
  value={brief}
195
  maxLength={2000}
196
  rows={8}
197
  onChange={(e) => setBrief(e.target.value)}
198
+ placeholder={t('setup.brief.placeholder')}
199
  />
200
  </div>
201
  <div className="nh-chat__submit">
202
+ <span className="hint">{t('setup.start.helper')}</span>
203
  <button
204
  type="submit"
205
  className="btn primary"
206
  disabled={!scenarioIdValid || !briefValid || busy}
207
  >
208
+ {busy ? <><Spinner /> {t('setup.start.busy')}</> : t('setup.start.cta')}
209
  </button>
210
  </div>
211
  </form>
 
224
  <>
225
  <section className="nh-hero">
226
  <div className="nh-eyebrow">
227
+ <b>{t('setup.chat.eyebrow.lead')}</b> · {t('setup.chat.eyebrow.case')}{' '}
228
+ <span className="mono">{session?.scenario_id}</span>
229
  <span>·</span>
230
+ <b>{ready ? t('setup.chat.status.ready') : t('setup.chat.status.gathering')}</b>
231
  </div>
232
  <h1>
233
+ {ready ? t('setup.chat.title.ready') : t('setup.chat.title.gathering')}
 
 
234
  </h1>
235
  </section>
236
 
 
252
  {msgs.map((m, i) => <Msg key={i} m={m} />)}
253
  {busy && (
254
  <div className="nh-msg control">
255
+ <div className="nh-msg__meta">
256
+ <span>{t('setup.chat.label.agent')}</span> {t('setup.chat.thinking')}
257
+ </div>
258
  <div className="nh-msg__body"><Spinner /></div>
259
  </div>
260
  )}
 
262
 
263
  {ready && session?.synthesized_brief && (
264
  <div className="nh-spec">
265
+ <span className="eyebrow">{t('setup.synthesis.eyebrow')}</span>
266
  <div className="setup-md" style={{ fontSize: 14, lineHeight: 1.55 }}>
267
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{session.synthesized_brief}</ReactMarkdown>
268
  </div>
269
  <div className="nh-spec__fn">
270
+ {t('setup.synthesis.footnote.lead')}{' '}
271
+ <span className="mono">scenarios/{session.scenario_id}/</span>{' '}
272
+ {t('setup.synthesis.footnote.tail')}
273
  </div>
274
  </div>
275
  )}
 
279
  <div className="nh-chat__creating">
280
  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
281
  <Spinner />
282
+ <span>
283
+ {t('setup.committing.lead')}{' '}
284
+ <span className="mono">{session?.scenario_id}</span>{' '}
285
+ {t('setup.committing.tail')}
286
+ </span>
287
  </div>
288
  <div className="hint mono fg-3" style={{ marginTop: 6 }}>
289
+ {t('setup.committing.helper')}
290
  </div>
291
  </div>
292
  ) : (
293
  <div className="nh-chat__approve">
294
  <div>
295
+ <span className="eyebrow">{t('setup.commit.eyebrow')}</span>
296
+ <div className="hd">{t('setup.commit.title')}</div>
297
  </div>
298
  <button className="btn primary" onClick={commitAndLaunch} disabled={busy}>
299
+ {t('setup.commit.cta')}
300
  </button>
301
  </div>
302
  )
 
314
  send();
315
  }
316
  }}
317
+ placeholder={t('setup.reply.placeholder')}
318
  disabled={busy}
319
  />
320
  <div className="nh-chat__submit">
321
+ <span className="hint">{t('setup.reply.charCount', { count: answer.length })}</span>
322
  <button
323
  type="submit"
324
  className="btn primary"
325
  disabled={!answer.trim() || busy}
326
  >
327
+ {busy ? <><Spinner /> {t('setup.reply.sending')}</> : t('setup.reply.send')}
328
  </button>
329
  </div>
330
  </form>
 
337
  }
338
 
339
  function Msg({ m }: { m: SetupMessage }) {
340
+ const { t } = useI18n();
341
  const who = m.role === 'user' ? 'user' : 'control';
342
+ const labelKey: StringKey = m.role === 'user' ? 'setup.chat.label.user' : 'setup.chat.label.agent';
343
  return (
344
  <div className={`nh-msg ${who}`}>
345
+ <div className="nh-msg__meta"><span>{t(labelKey)}</span> {fmtAt(m.at)}</div>
346
  <div className="nh-msg__body">
347
  {m.role === 'setup_agent' ? (
348
  <div className="setup-md">
 
372
  onUpload: (files: FileList | File[]) => void | Promise<void>;
373
  onRemove: (filename: string) => void | Promise<void>;
374
  }) {
375
+ const { t } = useI18n();
376
  const [drag, setDrag] = useState(false);
377
  const fileInputRef = useRef<HTMLInputElement | null>(null);
378
  const remaining = DOC_MAX_COUNT - docs.length;
 
381
  <section className="nh-doc-upload">
382
  <div className="nh-doc-upload__hd">
383
  <div>
384
+ <span className="eyebrow">{t('setup.docs.eyebrow')}</span>
385
+ <div className="hd">{t('setup.docs.title')}</div>
 
 
386
  </div>
387
+ <span className="mono fg-3">{t('setup.docs.count', { used: docs.length, max: DOC_MAX_COUNT })}</span>
388
  </div>
389
 
390
  <div
 
429
  />
430
  <div className="nh-doc-drop__cta">
431
  {busy ? (
432
+ <><Spinner /> {t('setup.docs.uploading')}</>
433
  ) : remaining <= 0 ? (
434
+ <span>{t('setup.docs.limitReached')}</span>
435
  ) : (
436
  <>
437
+ <strong>{t('setup.docs.dropLead')}</strong> {t('setup.docs.dropTail')}
438
+ <span className="mono"> {DOC_ALLOWED_EXT.join(' · ')}</span> · {t('setup.docs.dropMeta')}
439
  </>
440
  )}
441
  </div>
 
455
  onClick={() => onRemove(d.filename)}
456
  disabled={busy}
457
  >
458
+ {t('setup.docs.remove')}
459
  </button>
460
  </li>
461
  ))}
wargame/agents/base.py CHANGED
@@ -21,9 +21,11 @@ per-turn data into the user message.
21
 
22
  from __future__ import annotations
23
 
 
24
  import json
25
  import logging
26
  import re
 
27
  from contextvars import ContextVar
28
  from dataclasses import dataclass
29
  from typing import TYPE_CHECKING, TypeVar
@@ -113,6 +115,65 @@ _current_turn_usage: ContextVar["TurnUsage | None"] = ContextVar(
113
  )
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def begin_turn_usage() -> "TurnUsage":
117
  """Start a fresh per-turn TurnUsage and bind it in the current context."""
118
  from wargame.engine.state import TurnUsage
@@ -205,6 +266,11 @@ def call_structured(
205
  """
206
  model_id = model_for(role)
207
  spec = resolve_model(model_id)
 
 
 
 
 
208
  # Lazy import to avoid loading optional SDKs at module-import time.
209
  from wargame.agents.providers import dispatch
210
 
@@ -212,7 +278,7 @@ def call_structured(
212
  return provider_fn(
213
  schema=schema,
214
  system=system,
215
- user=user,
216
  model=model_id,
217
  role=role,
218
  temperature=temperature,
 
21
 
22
  from __future__ import annotations
23
 
24
+ import contextlib
25
  import json
26
  import logging
27
  import re
28
+ from collections.abc import Iterator
29
  from contextvars import ContextVar
30
  from dataclasses import dataclass
31
  from typing import TYPE_CHECKING, TypeVar
 
115
  )
116
 
117
 
118
+ # ---------------------------------------------------------------------------
119
+ # Per-game output language (ContextVar set by engine entry points)
120
+ #
121
+ # The system prompt is cache-stable across turns (Anthropic prompt caching
122
+ # requires byte-identical prefixes). The language directive is appended to
123
+ # the USER message so the cache stays warm AND the directive is stable
124
+ # within a language. Default is English so legacy snapshots without a
125
+ # `language` field continue to behave exactly as before.
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ _current_language: ContextVar[str] = ContextVar(
130
+ "wargame_current_language", default="en"
131
+ )
132
+
133
+
134
+ def language_directive(lang: str) -> str:
135
+ """Return the user-message suffix that pins the LLM's output language.
136
+
137
+ The German directive explicitly preserves proper nouns and quoted
138
+ references so the world bible's named entities (Norhard Thermal AG,
139
+ Bettina Lange, Aergis Climate, …) don't get translated mid-sentence,
140
+ and so contract-style citations stay verbatim.
141
+ """
142
+ if lang == "de":
143
+ return (
144
+ "Antworte ausschliesslich auf klarem, praezisem Deutsch. "
145
+ "Behalte alle Eigennamen (Personen, Firmen, Behoerden, Produkte) "
146
+ "im Original und uebersetze sie nicht (z. B. Norhard Thermal AG, "
147
+ "Aergis Climate, Bettina Lange, Bundeskartellamt). Halte "
148
+ "numerische Werte, Kennzahlen, Vertragsklauseln und woertliche "
149
+ "Zitate im Originalwortlaut. Nur Fliesstext, Erklaerungen und "
150
+ "Rollen-/Tonalitaetsangaben werden ins Deutsche uebertragen."
151
+ )
152
+ return "Respond in clear, concise English."
153
+
154
+
155
+ def current_language() -> str:
156
+ """Return the active output language for the current call context."""
157
+ return _current_language.get()
158
+
159
+
160
+ @contextlib.contextmanager
161
+ def use_game_language(lang: str) -> Iterator[None]:
162
+ """Pin the output language for every `call_structured` call in this scope.
163
+
164
+ Mirrors `wargame.config.use_game_model`: takes a ContextVar token and
165
+ restores the previous value on exit so nested scopes compose cleanly.
166
+ Unknown values fall back to English at directive-render time, so a
167
+ typoed value never crashes a turn — but `Game.language` is `Literal[
168
+ "en", "de"]` so the engine never gets there in practice.
169
+ """
170
+ token = _current_language.set(lang)
171
+ try:
172
+ yield
173
+ finally:
174
+ _current_language.reset(token)
175
+
176
+
177
  def begin_turn_usage() -> "TurnUsage":
178
  """Start a fresh per-turn TurnUsage and bind it in the current context."""
179
  from wargame.engine.state import TurnUsage
 
266
  """
267
  model_id = model_for(role)
268
  spec = resolve_model(model_id)
269
+ # Append the language directive to the USER message (NOT the system
270
+ # prompt) so the system prefix stays byte-identical across turns and
271
+ # Anthropic prompt caching keeps hitting. The directive itself is
272
+ # stable within a language, so cache effectiveness is preserved.
273
+ user_with_directive = f"{user}\n\n{language_directive(current_language())}\n"
274
  # Lazy import to avoid loading optional SDKs at module-import time.
275
  from wargame.agents.providers import dispatch
276
 
 
278
  return provider_fn(
279
  schema=schema,
280
  system=system,
281
+ user=user_with_directive,
282
  model=model_id,
283
  role=role,
284
  temperature=temperature,
wargame/agents/control.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
 
13
  from typing import TYPE_CHECKING
14
 
15
- from wargame.agents.base import call_structured
16
  from wargame.agents.prompts_loader import render
17
  from wargame.engine.state import (
18
  ActionSubmission,
@@ -30,6 +30,23 @@ if TYPE_CHECKING:
30
  from wargame.agents.registry import ScenarioConfig
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  # ---------------------------------------------------------------------------
34
  # Helpers
35
  # ---------------------------------------------------------------------------
@@ -37,8 +54,12 @@ if TYPE_CHECKING:
37
 
38
  def _metrics_table(world: WorldState) -> str:
39
  if not world.metrics:
40
- return "_(no metrics)_"
41
- lines = ["| Metric | Value | Δ last turn |", "|---|---|---|"]
 
 
 
 
42
  for m in world.metrics:
43
  delta = f"{m.delta_last_turn:+.2f}".rstrip("0").rstrip(".") if m.delta_last_turn else "—"
44
  lines.append(f"| {m.label} | {m.value:g} {m.unit} | {delta} |")
@@ -47,7 +68,7 @@ def _metrics_table(world: WorldState) -> str:
47
 
48
  def _recent_events(world: WorldState, limit: int = 12) -> str:
49
  if not world.recent_events:
50
- return "_(no recent events)_"
51
  return "\n".join(f"- {e}" for e in world.recent_events[-limit:])
52
 
53
 
@@ -58,29 +79,37 @@ def _delta_summary(game: Game) -> str:
58
  it reports the last turn's verdicts and metric movements.
59
  """
60
  if len(game.turns) < 2:
61
- return (
62
  "This is turn 1. The disrupting event has just landed; the player "
63
  "has not yet acted. Focus the briefing on what the disruption means "
64
- "for the CEO's next 10 days."
 
 
 
65
  )
66
  prev = game.turns[-2]
67
  lines: list[str] = []
68
  if prev.player_action:
69
- lines.append(f"Player acted last turn: {prev.player_action.intent!r}")
 
70
  if prev.verdicts:
71
- lines.append("Verdicts rendered:")
 
72
  for v in prev.verdicts:
73
  lines.append(
74
  f" - {v.action_actor}: {v.outcome.value} "
75
- f"(plausibility {v.plausibility}) — {v.effect_narrative}"
76
  )
77
  if game.world.metrics:
78
  moved = [m for m in game.world.metrics if abs(m.delta_last_turn) > 1e-9]
79
  if moved:
80
- lines.append("Metrics moved last turn:")
81
  for m in moved:
82
  lines.append(f" - {m.label}: {m.delta_last_turn:+g} {m.unit}")
83
- return "\n".join(lines) or "_(no meaningful delta)_"
 
 
 
84
 
85
 
86
  # ---------------------------------------------------------------------------
 
12
 
13
  from typing import TYPE_CHECKING
14
 
15
+ from wargame.agents.base import call_structured, current_language
16
  from wargame.agents.prompts_loader import render
17
  from wargame.engine.state import (
18
  ActionSubmission,
 
30
  from wargame.agents.registry import ScenarioConfig
31
 
32
 
33
+ # ---------------------------------------------------------------------------
34
+ # Localizable strings — only the small set of UI fragments the engine emits
35
+ # directly into the user message (the LLM doesn't author them, so the
36
+ # language directive can't translate them). Scenario reference data
37
+ # (world bible, agents.yaml) stays English by design.
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _t(en: str, de: str) -> str:
42
+ """Pick the EN or DE string based on the active language ContextVar.
43
+
44
+ Falls back to English when no language is bound — keeps the engine
45
+ safe to call from offline scripts that never set up a game.
46
+ """
47
+ return de if current_language() == "de" else en
48
+
49
+
50
  # ---------------------------------------------------------------------------
51
  # Helpers
52
  # ---------------------------------------------------------------------------
 
54
 
55
  def _metrics_table(world: WorldState) -> str:
56
  if not world.metrics:
57
+ return _t("_(no metrics)_", "_(keine Kennzahlen)_")
58
+ header = _t(
59
+ "| Metric | Value | Δ last turn |",
60
+ "| Kennzahl | Wert | Δ letzte Runde |",
61
+ )
62
+ lines = [header, "|---|---|---|"]
63
  for m in world.metrics:
64
  delta = f"{m.delta_last_turn:+.2f}".rstrip("0").rstrip(".") if m.delta_last_turn else "—"
65
  lines.append(f"| {m.label} | {m.value:g} {m.unit} | {delta} |")
 
68
 
69
  def _recent_events(world: WorldState, limit: int = 12) -> str:
70
  if not world.recent_events:
71
+ return _t("_(no recent events)_", "_(keine aktuellen Ereignisse)_")
72
  return "\n".join(f"- {e}" for e in world.recent_events[-limit:])
73
 
74
 
 
79
  it reports the last turn's verdicts and metric movements.
80
  """
81
  if len(game.turns) < 2:
82
+ return _t(
83
  "This is turn 1. The disrupting event has just landed; the player "
84
  "has not yet acted. Focus the briefing on what the disruption means "
85
+ "for the CEO's next 10 days.",
86
+ "Dies ist Runde 1. Das auslösende Ereignis ist gerade eingetreten; "
87
+ "der Spieler hat noch nicht gehandelt. Konzentriere das Briefing "
88
+ "darauf, was die Disruption für die nächsten 10 Tage des CEO bedeutet.",
89
  )
90
  prev = game.turns[-2]
91
  lines: list[str] = []
92
  if prev.player_action:
93
+ prefix = _t("Player acted last turn", "Spieler-Aktion in letzter Runde")
94
+ lines.append(f"{prefix}: {prev.player_action.intent!r}")
95
  if prev.verdicts:
96
+ lines.append(_t("Verdicts rendered:", "Gefällte Urteile:"))
97
+ plaus_label = _t("plausibility", "Plausibilität")
98
  for v in prev.verdicts:
99
  lines.append(
100
  f" - {v.action_actor}: {v.outcome.value} "
101
+ f"({plaus_label} {v.plausibility}) — {v.effect_narrative}"
102
  )
103
  if game.world.metrics:
104
  moved = [m for m in game.world.metrics if abs(m.delta_last_turn) > 1e-9]
105
  if moved:
106
+ lines.append(_t("Metrics moved last turn:", "Bewegte Kennzahlen letzte Runde:"))
107
  for m in moved:
108
  lines.append(f" - {m.label}: {m.delta_last_turn:+g} {m.unit}")
109
+ return "\n".join(lines) or _t(
110
+ "_(no meaningful delta)_",
111
+ "_(keine wesentliche Veränderung)_",
112
+ )
113
 
114
 
115
  # ---------------------------------------------------------------------------
wargame/api/routes.py CHANGED
@@ -238,6 +238,7 @@ def create_game(body: NewGameRequest) -> NewGameResponse:
238
  body.scenario_id,
239
  model=spec.id,
240
  role_model_overrides=body.role_model_overrides,
 
241
  )
242
  except ValueError as e:
243
  raise HTTPException(status_code=422, detail=str(e)) from None
@@ -282,6 +283,7 @@ def get_state(game_id: str) -> StateResponse:
282
  recon_budget_remaining=game.recon_budget_remaining,
283
  current_phase=phase,
284
  turns=game.turns,
 
285
  )
286
 
287
 
@@ -404,6 +406,7 @@ def post_pause(game_id: str) -> StateResponse:
404
  recon_budget_remaining=game.recon_budget_remaining,
405
  current_phase=phase,
406
  turns=game.turns,
 
407
  )
408
 
409
 
 
238
  body.scenario_id,
239
  model=spec.id,
240
  role_model_overrides=body.role_model_overrides,
241
+ language=body.language,
242
  )
243
  except ValueError as e:
244
  raise HTTPException(status_code=422, detail=str(e)) from None
 
283
  recon_budget_remaining=game.recon_budget_remaining,
284
  current_phase=phase,
285
  turns=game.turns,
286
+ language=game.language,
287
  )
288
 
289
 
 
406
  recon_budget_remaining=game.recon_budget_remaining,
407
  current_phase=phase,
408
  turns=game.turns,
409
+ language=game.language,
410
  )
411
 
412
 
wargame/api/schemas.py CHANGED
@@ -6,6 +6,8 @@ models — we only add HTTP-specific envelopes and request bodies here.
6
 
7
  from __future__ import annotations
8
 
 
 
9
  from pydantic import BaseModel, Field
10
 
11
  from wargame.engine.state import (
@@ -51,6 +53,14 @@ class NewGameRequest(BaseModel):
51
  "Call `GET /models` for the list of canonical role tags."
52
  ),
53
  )
 
 
 
 
 
 
 
 
54
 
55
 
56
  class ReconRequest(BaseModel):
@@ -112,6 +122,13 @@ class StateResponse(BaseModel):
112
  recon_budget_remaining: int
113
  current_phase: str
114
  turns: list[TurnRecord]
 
 
 
 
 
 
 
115
 
116
 
117
  class BriefingResponse(BaseModel):
 
6
 
7
  from __future__ import annotations
8
 
9
+ from typing import Literal
10
+
11
  from pydantic import BaseModel, Field
12
 
13
  from wargame.engine.state import (
 
53
  "Call `GET /models` for the list of canonical role tags."
54
  ),
55
  )
56
+ language: Literal["en", "de"] = Field(
57
+ default="en",
58
+ description=(
59
+ "Output language for every LLM-generated user-facing string in "
60
+ "this game. Scenario reference data stays English; only the "
61
+ "LLM's output language changes."
62
+ ),
63
+ )
64
 
65
 
66
  class ReconRequest(BaseModel):
 
122
  recon_budget_remaining: int
123
  current_phase: str
124
  turns: list[TurnRecord]
125
+ language: Literal["en", "de"] = Field(
126
+ default="en",
127
+ description=(
128
+ "Output language pinned to this game. Frontend can branch on "
129
+ "this to localize its own static UI chrome to match."
130
+ ),
131
+ )
132
 
133
 
134
  class BriefingResponse(BaseModel):
wargame/cli.py CHANGED
@@ -207,6 +207,17 @@ def cmd_new(
207
  ),
208
  ),
209
  ] = None,
 
 
 
 
 
 
 
 
 
 
 
210
  orchestrate: Annotated[
211
  bool,
212
  typer.Option(
@@ -241,9 +252,14 @@ def cmd_new(
241
  except ValueError as e:
242
  raise typer.BadParameter(str(e)) from None
243
  overrides = _parse_model_for_flags(model_for)
 
 
 
 
244
  console.print(
245
  f"[bold]Starting new game:[/bold] scenario=[cyan]{scenario}[/cyan] "
246
- f"model=[cyan]{spec.id}[/cyan]"
 
247
  + (
248
  f" overrides=[cyan]{', '.join(f'{r}={m}' for r, m in overrides.items())}[/cyan]"
249
  if overrides
@@ -251,7 +267,12 @@ def cmd_new(
251
  )
252
  )
253
  try:
254
- game = loop.new_game(scenario, model=spec.id, role_model_overrides=overrides)
 
 
 
 
 
255
  except ValueError as e:
256
  raise typer.BadParameter(str(e)) from None
257
  scen = load_scenario(scenario)
@@ -381,7 +402,8 @@ def cmd_state(
381
  f"[bold]{w.company_name}[/bold] vs [bold]{w.rival_name}[/bold]\n"
382
  f"Industry: {w.industry}\n"
383
  f"Turn: {w.turn}/{w.max_turns} Status: {game.status} "
384
- f"Media tone: [magenta]{w.media_tone.value}[/magenta]\n\n"
 
385
  f"{w.public_narrative}",
386
  title="World state",
387
  border_style="cyan",
 
207
  ),
208
  ),
209
  ] = None,
210
+ language: Annotated[
211
+ str,
212
+ typer.Option(
213
+ "--language",
214
+ help=(
215
+ "Output language for every LLM-generated user-facing string "
216
+ "in this game. Supported: en, de. Scenario reference data "
217
+ "(world bible, agent personas) stays English regardless."
218
+ ),
219
+ ),
220
+ ] = "en",
221
  orchestrate: Annotated[
222
  bool,
223
  typer.Option(
 
252
  except ValueError as e:
253
  raise typer.BadParameter(str(e)) from None
254
  overrides = _parse_model_for_flags(model_for)
255
+ if language not in ("en", "de"):
256
+ raise typer.BadParameter(
257
+ f"--language must be one of: en, de (got {language!r})."
258
+ )
259
  console.print(
260
  f"[bold]Starting new game:[/bold] scenario=[cyan]{scenario}[/cyan] "
261
+ f"model=[cyan]{spec.id}[/cyan] "
262
+ f"language=[cyan]{language}[/cyan]"
263
  + (
264
  f" overrides=[cyan]{', '.join(f'{r}={m}' for r, m in overrides.items())}[/cyan]"
265
  if overrides
 
267
  )
268
  )
269
  try:
270
+ game = loop.new_game(
271
+ scenario,
272
+ model=spec.id,
273
+ role_model_overrides=overrides,
274
+ language=language,
275
+ )
276
  except ValueError as e:
277
  raise typer.BadParameter(str(e)) from None
278
  scen = load_scenario(scenario)
 
402
  f"[bold]{w.company_name}[/bold] vs [bold]{w.rival_name}[/bold]\n"
403
  f"Industry: {w.industry}\n"
404
  f"Turn: {w.turn}/{w.max_turns} Status: {game.status} "
405
+ f"Media tone: [magenta]{w.media_tone.value}[/magenta] "
406
+ f"Language: [cyan]{game.language}[/cyan]\n\n"
407
  f"{w.public_narrative}",
408
  title="World state",
409
  border_style="cyan",
wargame/engine/loop.py CHANGED
@@ -16,7 +16,7 @@ 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
20
  from wargame.agents.registry import ScenarioConfig, load_scenario
21
  from wargame.agents.roles import CANONICAL_ROLES
22
  from wargame.config import resolve_model, use_game_model
@@ -123,6 +123,7 @@ def new_game(
123
  *,
124
  model: str | None = None,
125
  role_model_overrides: dict[str, str] | None = None,
 
126
  ) -> Game:
127
  scenario = load_scenario(scenario_id)
128
  # Validate model early so a bad id doesn't blow up mid-briefing.
@@ -133,6 +134,11 @@ def new_game(
133
  # rest of the engine never has to handle an invalid override.
134
  validated_overrides = _validate_overrides_at_boundary(role_model_overrides or {})
135
 
 
 
 
 
 
136
  world = _load_initial_world_state(scenario)
137
  world.turn = 1 # first playable turn
138
 
@@ -145,13 +151,14 @@ def new_game(
145
  scenario_id=scenario_id,
146
  llm_model=spec.id,
147
  role_model_overrides=validated_overrides,
 
148
  world=world,
149
  private=_init_private_state(scenario),
150
  recon_budget_remaining=RECON_BUDGET_PER_TURN,
151
  hard_constraints=initial_constraints,
152
  )
153
 
154
- with use_game_model(game.llm_model, game.role_model_overrides):
155
  # Seed the first TurnRecord up front so begin/end_turn_usage can write
156
  # to it as the rival plan + briefing calls happen.
157
  first_turn = TurnRecord(turn=1, phase=Phase.BRIEFING)
@@ -186,7 +193,7 @@ def new_game(
186
  def query(game: Game, agent_id: str, question: str) -> ReconResponse:
187
  scenario = load_scenario(game.scenario_id)
188
  current = game.current_turn
189
- with use_game_model(game.llm_model, game.role_model_overrides):
190
  begin_turn_usage()
191
  try:
192
  response = phases.answer_recon(scenario, game, agent_id, question)
@@ -218,7 +225,7 @@ def act(game: Game, action: ActionSubmission) -> None:
218
  """
219
  if game.hard_constraints:
220
  scenario = load_scenario(game.scenario_id)
221
- with use_game_model(game.llm_model, game.role_model_overrides):
222
  result = constraint_checker.check(scenario, game, action)
223
  if not result.passes:
224
  raise HardConstraintViolation(violations=result.violations)
@@ -235,7 +242,7 @@ def check_action(game: Game, action: ActionSubmission) -> ConstraintCheckResult:
235
  if not game.hard_constraints:
236
  return ConstraintCheckResult(passes=True, violations=[])
237
  scenario = load_scenario(game.scenario_id)
238
- with use_game_model(game.llm_model, game.role_model_overrides):
239
  return constraint_checker.check(scenario, game, action)
240
 
241
 
@@ -306,7 +313,7 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
306
  f"advance() requires phase=ACTION (player must act first). Current: {current.phase}"
307
  )
308
 
309
- with use_game_model(game.llm_model, game.role_model_overrides):
310
  with events.bind_publisher(game.id, current.turn):
311
  # Accumulate this turn's closing LLM work onto the closing TurnRecord.
312
  # `current.usage` may already carry the briefing cost from new_game /
@@ -446,7 +453,7 @@ def coach_note(game: Game) -> CoachNote:
446
  if game.status != "complete":
447
  raise ValueError("Coach note is only available after the game completes.")
448
  scenario = load_scenario(game.scenario_id)
449
- with use_game_model(game.llm_model, game.role_model_overrides):
450
  note = control.write_coach_note(scenario, game)
451
  return note
452
 
@@ -466,7 +473,7 @@ def peek(
466
  turn: int | None = None,
467
  ) -> tuple[AgentDeliberation, PeekRecord]:
468
  """Reveal one past deliberation from `agent_id` (default: latest)."""
469
- with use_game_model(game.llm_model, game.role_model_overrides):
470
  deliberation, record = phases.peek_agent(game, agent_id, turn=turn)
471
  persistence.save(game)
472
  return deliberation, record
@@ -492,7 +499,7 @@ def challenge_verdict(
492
  ) -> VerdictChallenge:
493
  """Record a player challenge; optionally kick off re-adjudication async."""
494
  scenario = load_scenario(game.scenario_id)
495
- with use_game_model(game.llm_model, game.role_model_overrides):
496
  challenge = phases.record_challenge(
497
  scenario=scenario,
498
  game=game,
@@ -529,7 +536,7 @@ def _resolve_challenge_worker(game_id: str, turn: int, challenge_idx: int) -> No
529
  )
530
  return
531
  challenge = target_turn.challenges[challenge_idx]
532
- with use_game_model(game.llm_model, game.role_model_overrides):
533
  phases.resolve_challenge(scenario, game, challenge)
534
  persistence.save(game)
535
  log.info(
 
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
21
  from wargame.agents.roles import CANONICAL_ROLES
22
  from wargame.config import resolve_model, use_game_model
 
123
  *,
124
  model: str | None = None,
125
  role_model_overrides: dict[str, str] | None = None,
126
+ language: str = "en",
127
  ) -> Game:
128
  scenario = load_scenario(scenario_id)
129
  # Validate model early so a bad id doesn't blow up mid-briefing.
 
134
  # rest of the engine never has to handle an invalid override.
135
  validated_overrides = _validate_overrides_at_boundary(role_model_overrides or {})
136
 
137
+ if language not in ("en", "de"):
138
+ raise ValueError(
139
+ f"Unknown language {language!r}. Supported: 'en', 'de'."
140
+ )
141
+
142
  world = _load_initial_world_state(scenario)
143
  world.turn = 1 # first playable turn
144
 
 
151
  scenario_id=scenario_id,
152
  llm_model=spec.id,
153
  role_model_overrides=validated_overrides,
154
+ language=language,
155
  world=world,
156
  private=_init_private_state(scenario),
157
  recon_budget_remaining=RECON_BUDGET_PER_TURN,
158
  hard_constraints=initial_constraints,
159
  )
160
 
161
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
162
  # Seed the first TurnRecord up front so begin/end_turn_usage can write
163
  # to it as the rival plan + briefing calls happen.
164
  first_turn = TurnRecord(turn=1, phase=Phase.BRIEFING)
 
193
  def query(game: Game, agent_id: str, question: str) -> ReconResponse:
194
  scenario = load_scenario(game.scenario_id)
195
  current = game.current_turn
196
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
197
  begin_turn_usage()
198
  try:
199
  response = phases.answer_recon(scenario, game, agent_id, question)
 
225
  """
226
  if game.hard_constraints:
227
  scenario = load_scenario(game.scenario_id)
228
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
229
  result = constraint_checker.check(scenario, game, action)
230
  if not result.passes:
231
  raise HardConstraintViolation(violations=result.violations)
 
242
  if not game.hard_constraints:
243
  return ConstraintCheckResult(passes=True, violations=[])
244
  scenario = load_scenario(game.scenario_id)
245
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
246
  return constraint_checker.check(scenario, game, action)
247
 
248
 
 
313
  f"advance() requires phase=ACTION (player must act first). Current: {current.phase}"
314
  )
315
 
316
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
317
  with events.bind_publisher(game.id, current.turn):
318
  # Accumulate this turn's closing LLM work onto the closing TurnRecord.
319
  # `current.usage` may already carry the briefing cost from new_game /
 
453
  if game.status != "complete":
454
  raise ValueError("Coach note is only available after the game completes.")
455
  scenario = load_scenario(game.scenario_id)
456
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
457
  note = control.write_coach_note(scenario, game)
458
  return note
459
 
 
473
  turn: int | None = None,
474
  ) -> tuple[AgentDeliberation, PeekRecord]:
475
  """Reveal one past deliberation from `agent_id` (default: latest)."""
476
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
477
  deliberation, record = phases.peek_agent(game, agent_id, turn=turn)
478
  persistence.save(game)
479
  return deliberation, record
 
499
  ) -> VerdictChallenge:
500
  """Record a player challenge; optionally kick off re-adjudication async."""
501
  scenario = load_scenario(game.scenario_id)
502
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
503
  challenge = phases.record_challenge(
504
  scenario=scenario,
505
  game=game,
 
536
  )
537
  return
538
  challenge = target_turn.challenges[challenge_idx]
539
+ with use_game_model(game.llm_model, game.role_model_overrides), use_game_language(game.language):
540
  phases.resolve_challenge(scenario, game, challenge)
541
  persistence.save(game)
542
  log.info(
wargame/engine/phases.py CHANGED
@@ -168,13 +168,23 @@ def run_deliberations(
168
 
169
  # Fan out the LLM calls — each deliberation is independent. HTTP waits
170
  # dominate, so threads are sufficient (no need for asyncio).
 
 
 
 
 
 
 
 
171
  workers = int(os.getenv("WARGAME_DELIBERATION_WORKERS", "8"))
172
  deliberations: list[AgentDeliberation] = [None] * len(specs) # type: ignore[list-item]
173
  with ThreadPoolExecutor(max_workers=workers) as pool:
174
- for idx, d in zip(
175
- range(len(specs)),
176
- pool.map(_deliberate_one, specs),
177
- ):
 
 
178
  deliberations[idx] = d
179
 
180
  # Serialize mutations on game state after parallel work completes.
 
168
 
169
  # Fan out the LLM calls — each deliberation is independent. HTTP waits
170
  # dominate, so threads are sufficient (no need for asyncio).
171
+ #
172
+ # ThreadPoolExecutor workers do NOT inherit the caller's ContextVar
173
+ # context, so without copying the context the per-game model and
174
+ # language ContextVars would default-out inside the worker and every
175
+ # deliberation call would silently route to `default_model()` and the
176
+ # English directive. `contextvars.copy_context()` snapshots the
177
+ # caller's bindings; `ctx.run(...)` re-enters them on the worker.
178
+ import contextvars as _contextvars
179
  workers = int(os.getenv("WARGAME_DELIBERATION_WORKERS", "8"))
180
  deliberations: list[AgentDeliberation] = [None] * len(specs) # type: ignore[list-item]
181
  with ThreadPoolExecutor(max_workers=workers) as pool:
182
+ ctxs = [_contextvars.copy_context() for _ in specs]
183
+ results = pool.map(
184
+ lambda pair: pair[0].run(_deliberate_one, pair[1]),
185
+ zip(ctxs, specs),
186
+ )
187
+ for idx, d in zip(range(len(specs)), results):
188
  deliberations[idx] = d
189
 
190
  # Serialize mutations on game state after parallel work completes.
wargame/engine/state.py CHANGED
@@ -533,6 +533,17 @@ class Game(BaseModel):
533
  "Critic / Pre-mortem / Evaluator to a cheaper model than Control."
534
  ),
535
  )
 
 
 
 
 
 
 
 
 
 
 
536
  world: WorldState
537
  private: dict[str, AgentPrivateState] = Field(
538
  default_factory=dict,
 
533
  "Critic / Pre-mortem / Evaluator to a cheaper model than Control."
534
  ),
535
  )
536
+ language: Literal["en", "de"] = Field(
537
+ default="en",
538
+ description=(
539
+ "Output language for every LLM-generated user-facing string in this "
540
+ "game (briefings, deliberations, verdicts, world updates, debrief, "
541
+ "recon answers, critic / premortem / evaluator outputs, coach notes). "
542
+ "Scenario reference data (world_bible.md, agents.yaml) stays English "
543
+ "regardless — only the LLM's output language changes. Old snapshots "
544
+ "without this field load as 'en'."
545
+ ),
546
+ )
547
  world: WorldState
548
  private: dict[str, AgentPrivateState] = Field(
549
  default_factory=dict,
wargame/orchestrator/runner.py CHANGED
@@ -25,6 +25,7 @@ import logging
25
  from typing import TYPE_CHECKING
26
 
27
  from wargame.agents import role as role_agent
 
28
  from wargame.agents.registry import load_scenario
29
  from wargame.config import use_game_model
30
  from wargame.engine import loop as engine_loop
@@ -110,7 +111,7 @@ def run_orchestration(
110
  # `engine_loop.act` / `engine_loop.advance`, which have their own inner
111
  # `use_game_model` wraps — those re-pin inside the daemon worker thread
112
  # at loop.py:_resolve_challenge_worker, which doesn't inherit contexts.
113
- with use_game_model(source_game.llm_model, source_game.role_model_overrides):
114
  try:
115
  log.info(
116
  "Orchestration %s: generating %d strategy vectors…",
 
25
  from typing import TYPE_CHECKING
26
 
27
  from wargame.agents import role as role_agent
28
+ from wargame.agents.base import use_game_language
29
  from wargame.agents.registry import load_scenario
30
  from wargame.config import use_game_model
31
  from wargame.engine import loop as engine_loop
 
111
  # `engine_loop.act` / `engine_loop.advance`, which have their own inner
112
  # `use_game_model` wraps — those re-pin inside the daemon worker thread
113
  # at loop.py:_resolve_challenge_worker, which doesn't inherit contexts.
114
+ with use_game_model(source_game.llm_model, source_game.role_model_overrides), use_game_language(source_game.language):
115
  try:
116
  log.info(
117
  "Orchestration %s: generating %d strategy vectors…",
wargame/tests/test_api.py CHANGED
@@ -227,6 +227,46 @@ def test_new_game_rejects_unknown_override_model(client: TestClient) -> None:
227
  assert "Unknown model" in r.json()["detail"]
228
 
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  # ---------------------------------------------------------------------------
231
  # Scenario listing + scaffolding
232
  # ---------------------------------------------------------------------------
 
227
  assert "Unknown model" in r.json()["detail"]
228
 
229
 
230
+ def test_new_game_default_language_is_english(client: TestClient) -> None:
231
+ """POST /games without `language` defaults to en; GET state surfaces it."""
232
+ r = client.post("/games", json={"scenario_id": "competitor_disruption"})
233
+ assert r.status_code == 201
234
+ gid = r.json()["game_id"]
235
+
236
+ r = client.get(f"/games/{gid}/state")
237
+ assert r.status_code == 200
238
+ assert r.json()["language"] == "en"
239
+
240
+
241
+ def test_new_game_with_german_language(client: TestClient) -> None:
242
+ """POST /games with language='de' persists and surfaces via GET state."""
243
+ r = client.post(
244
+ "/games",
245
+ json={"scenario_id": "competitor_disruption", "language": "de"},
246
+ )
247
+ assert r.status_code == 201
248
+ gid = r.json()["game_id"]
249
+
250
+ r = client.get(f"/games/{gid}/state")
251
+ assert r.status_code == 200
252
+ assert r.json()["language"] == "de"
253
+
254
+ # Round-trip via persistence too — the field landed on the snapshot.
255
+ from wargame.engine import persistence
256
+
257
+ game = persistence.load(gid)
258
+ assert game.language == "de"
259
+
260
+
261
+ def test_new_game_rejects_unknown_language(client: TestClient) -> None:
262
+ """An unsupported language value returns 422 at the request boundary."""
263
+ r = client.post(
264
+ "/games",
265
+ json={"scenario_id": "competitor_disruption", "language": "fr"},
266
+ )
267
+ assert r.status_code == 422
268
+
269
+
270
  # ---------------------------------------------------------------------------
271
  # Scenario listing + scaffolding
272
  # ---------------------------------------------------------------------------
wargame/tests/test_phases.py CHANGED
@@ -304,3 +304,163 @@ class TestRedTeam:
304
  assert player_score is not None
305
  assert player_score.deviation_from_control is not None
306
  assert player_score.deviation_from_control > 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  assert player_score is not None
305
  assert player_score.deviation_from_control is not None
306
  assert player_score.deviation_from_control > 0.0
307
+
308
+
309
+ class TestLanguageDirective:
310
+ """Round-6 i18n: a configured language must reach every LLM call.
311
+
312
+ The conftest `fake_llm` fixture monkeypatches `call_structured` directly
313
+ at every call site, bypassing the directive-injection that `call_structured`
314
+ actually performs. To test the injection itself, we patch only the
315
+ provider dispatch *below* `call_structured` — that lets the real
316
+ `call_structured` run, append the directive, and pass the modified user
317
+ message down to a capturing fake.
318
+ """
319
+
320
+ @staticmethod
321
+ def _capturing_provider(
322
+ captured: list[dict],
323
+ canned_dispatch,
324
+ ):
325
+ """Build a provider fn that records the (model, user) it was called
326
+ with and returns canned data via the conftest's `_dispatch` map."""
327
+
328
+ def provider(
329
+ *,
330
+ schema,
331
+ system: str,
332
+ user: str,
333
+ model: str,
334
+ role: str,
335
+ temperature: float,
336
+ max_tokens: int,
337
+ ):
338
+ captured.append(
339
+ {
340
+ "schema": schema.__name__,
341
+ "system": system,
342
+ "user": user,
343
+ "model": model,
344
+ "role": role,
345
+ }
346
+ )
347
+ return schema.model_validate(canned_dispatch(schema.__name__, user))
348
+
349
+ return provider
350
+
351
+ def _patch_real_call_structured(self, monkeypatch) -> list[dict]:
352
+ """Install a capturing provider in place of the real dispatch.
353
+
354
+ The real `call_structured` runs (so the directive gets appended to
355
+ the user message), then routes to our capturing provider instead of
356
+ Anthropic / Azure. Returns the list that captures user/system/model
357
+ for every LLM call made inside the wrapped code path.
358
+ """
359
+ from wargame.agents import providers
360
+ from wargame.tests.conftest import _dispatch
361
+
362
+ captured: list[dict] = []
363
+ provider = self._capturing_provider(captured, _dispatch)
364
+ # Override `dispatch(...)` so every provider lookup returns our fn.
365
+ monkeypatch.setattr(providers, "dispatch", lambda _kind: provider)
366
+ return captured
367
+
368
+ def test_directive_appended_to_user_message_when_de(
369
+ self, monkeypatch
370
+ ) -> None:
371
+ from wargame.engine import loop
372
+
373
+ captured = self._patch_real_call_structured(monkeypatch)
374
+ game = loop.new_game("competitor_disruption", language="de")
375
+ loop.act(game, _player_action())
376
+ loop.advance(game)
377
+
378
+ assert captured, "no LLM calls captured — provider patch did not engage."
379
+ # Every captured user message must end with the German directive.
380
+ for entry in captured:
381
+ user = entry["user"]
382
+ assert "Antworte" in user, (
383
+ f"Expected German directive in user message for "
384
+ f"{entry['schema']}, got tail: {user[-200:]!r}"
385
+ )
386
+ # Sanity-check the proper-noun preservation clause is present.
387
+ assert "Eigennamen" in user
388
+
389
+ def test_directive_appended_to_user_message_when_en(
390
+ self, monkeypatch
391
+ ) -> None:
392
+ from wargame.engine import loop
393
+
394
+ captured = self._patch_real_call_structured(monkeypatch)
395
+ # Default language is English.
396
+ game = loop.new_game("competitor_disruption")
397
+ loop.act(game, _player_action())
398
+ loop.advance(game)
399
+
400
+ assert captured
401
+ for entry in captured:
402
+ user = entry["user"]
403
+ assert "Respond in clear, concise English." in user, (
404
+ f"Expected English directive in user message for "
405
+ f"{entry['schema']}, got tail: {user[-200:]!r}"
406
+ )
407
+
408
+ def test_system_prompt_byte_identical_across_languages(
409
+ self, monkeypatch
410
+ ) -> None:
411
+ """Cache-stability invariant: the system prefix MUST NOT depend on
412
+ the configured language. Anthropic prompt caching keys on the
413
+ system prefix; if it diverged, every language switch would burn
414
+ the cache. The directive lives in user, never in system.
415
+ """
416
+ from wargame.engine import loop
417
+
418
+ # Capture an English run and a German run, then compare every
419
+ # (schema, system) pair across the two — they MUST be identical.
420
+ captured_en = self._patch_real_call_structured(monkeypatch)
421
+ game_en = loop.new_game("competitor_disruption")
422
+ loop.act(game_en, _player_action())
423
+ loop.advance(game_en)
424
+
425
+ captured_de = self._patch_real_call_structured(monkeypatch)
426
+ game_de = loop.new_game("competitor_disruption", language="de")
427
+ loop.act(game_de, _player_action())
428
+ loop.advance(game_de)
429
+
430
+ # Build (schema_name, system) lists for each run.
431
+ en_systems = {(e["schema"], e["system"]) for e in captured_en}
432
+ de_systems = {(e["schema"], e["system"]) for e in captured_de}
433
+ # Every system prompt that appeared in DE must also appear identically
434
+ # in EN; the system prefix doesn't shift on language.
435
+ common_schemas = {s for s, _ in en_systems} & {s for s, _ in de_systems}
436
+ for schema_name in common_schemas:
437
+ en_for_schema = {sys for s, sys in en_systems if s == schema_name}
438
+ de_for_schema = {sys for s, sys in de_systems if s == schema_name}
439
+ # The two sets must overlap — at least one byte-identical match.
440
+ assert en_for_schema & de_for_schema, (
441
+ f"Schema {schema_name}: EN system prompts differ from DE. "
442
+ "The language directive must NOT bleed into the system prefix."
443
+ )
444
+
445
+ def test_language_directive_helper_returns_expected_strings(self) -> None:
446
+ from wargame.agents.base import language_directive
447
+
448
+ en = language_directive("en")
449
+ de = language_directive("de")
450
+ assert "English" in en
451
+ assert "Antworte" in de
452
+ assert "Eigennamen" in de
453
+ # Unknown language defaults to English directive.
454
+ assert language_directive("fr") == en
455
+
456
+ def test_use_game_language_context_manager(self) -> None:
457
+ from wargame.agents.base import current_language, use_game_language
458
+
459
+ # Default outside any with-block.
460
+ assert current_language() == "en"
461
+ with use_game_language("de"):
462
+ assert current_language() == "de"
463
+ with use_game_language("en"):
464
+ assert current_language() == "en"
465
+ assert current_language() == "de"
466
+ assert current_language() == "en"
wargame/tests/test_state.py CHANGED
@@ -467,3 +467,41 @@ class TestGameRoleModelOverrides:
467
 
468
  with pytest.raises(typer.BadParameter, match="role=model_id"):
469
  _parse_model_for_flags(["control"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
 
468
  with pytest.raises(typer.BadParameter, match="role=model_id"):
469
  _parse_model_for_flags(["control"])
470
+
471
+
472
+ class TestGameLanguage:
473
+ def _world(self) -> WorldState:
474
+ return WorldState(
475
+ company_name="N",
476
+ rival_name="A",
477
+ industry="heat pumps",
478
+ turn=1,
479
+ )
480
+
481
+ def test_default_is_english(self) -> None:
482
+ game = Game(scenario_id="x", world=self._world())
483
+ assert game.language == "en"
484
+
485
+ def test_explicit_de(self) -> None:
486
+ game = Game(scenario_id="x", world=self._world(), language="de")
487
+ assert game.language == "de"
488
+
489
+ def test_round_trip_through_dump_validate(self) -> None:
490
+ game = Game(scenario_id="x", world=self._world(), language="de")
491
+ dumped = game.model_dump()
492
+ assert dumped["language"] == "de"
493
+ rehydrated = Game.model_validate(dumped)
494
+ assert rehydrated.language == "de"
495
+
496
+ def test_legacy_snapshot_without_field_loads_as_english(self) -> None:
497
+ legacy = {
498
+ "id": "abc",
499
+ "scenario_id": "x",
500
+ "world": self._world().model_dump(),
501
+ }
502
+ rehydrated = Game.model_validate(legacy)
503
+ assert rehydrated.language == "en"
504
+
505
+ def test_invalid_language_rejected(self) -> None:
506
+ with pytest.raises(ValidationError):
507
+ Game(scenario_id="x", world=self._world(), language="fr")