Spaces:
Sleeping
Round 6: German localization (en + de)
Browse filesBackend (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 +10 -0
- frontend/src/App.tsx +4 -2
- frontend/src/api.ts +4 -1
- frontend/src/components/LiveProgress.tsx +43 -33
- frontend/src/components/Shell.tsx +100 -20
- frontend/src/i18n/de.ts +373 -0
- frontend/src/i18n/en.ts +373 -0
- frontend/src/i18n/index.ts +92 -0
- frontend/src/screens/Debrief.tsx +17 -12
- frontend/src/screens/GameView.tsx +263 -176
- frontend/src/screens/GamesList.tsx +25 -17
- frontend/src/screens/NewGame.tsx +108 -45
- frontend/src/screens/SetupCase.tsx +55 -51
- wargame/agents/base.py +67 -1
- wargame/agents/control.py +40 -11
- wargame/api/routes.py +3 -0
- wargame/api/schemas.py +17 -0
- wargame/cli.py +25 -3
- wargame/engine/loop.py +17 -10
- wargame/engine/phases.py +14 -4
- wargame/engine/state.py +11 -0
- wargame/orchestrator/runner.py +2 -1
- wargame/tests/test_api.py +40 -0
- wargame/tests/test_phases.py +160 -0
- wargame/tests/test_state.py +38 -0
|
@@ -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
|
|
@@ -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>
|
| 56 |
-
<span className="mono">
|
| 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 |
);
|
|
@@ -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;
|
|
@@ -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 |
-
|
|
|
|
| 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',
|
| 24 |
-
{ key: 'critic',
|
| 25 |
-
{ key: 'premortem',
|
| 26 |
-
{ key: 'adjudication',
|
| 27 |
-
{ key: 'evaluator',
|
| 28 |
-
{ key: 'world_update',
|
| 29 |
-
{ key: 'next_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 || '
|
| 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('
|
| 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">
|
| 267 |
<h3 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
|
| 268 |
{pausedAt
|
| 269 |
-
?
|
| 270 |
-
: '
|
| 271 |
</h3>
|
| 272 |
</div>
|
| 273 |
-
<div className="nh-live__cost mono" aria-label=
|
| 274 |
-
${cost.toFixed(4)} <span className="fg-3">
|
| 275 |
</div>
|
| 276 |
</div>
|
| 277 |
|
| 278 |
{pausedAt && (
|
| 279 |
<div className="nh-live__paused">
|
| 280 |
-
<span className="eyebrow">
|
| 281 |
-
<span className="fg-2">
|
| 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=
|
| 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.
|
| 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">
|
| 310 |
{deliberations.length === 0 ? (
|
| 311 |
<p className="fg-3 body-sm" style={{ marginTop: 8 }}>
|
| 312 |
{stepStates.deliberation === 'running'
|
| 313 |
-
? <><Spinner />
|
| 314 |
-
: '
|
| 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">
|
|
|
|
|
|
|
| 323 |
)}
|
| 324 |
{d.fears && (
|
| 325 |
-
<div className="fe">
|
|
|
|
|
|
|
| 326 |
)}
|
| 327 |
</li>
|
| 328 |
))}
|
|
@@ -331,10 +338,10 @@ export function LiveProgress({
|
|
| 331 |
</div>
|
| 332 |
|
| 333 |
<div className="nh-live__panel">
|
| 334 |
-
<span className="eyebrow">
|
| 335 |
{verdicts.length === 0 ? (
|
| 336 |
<p className="fg-3 body-sm" style={{ marginTop: 8 }}>
|
| 337 |
-
|
| 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">
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 369 |
switch (o) {
|
| 370 |
-
case 'effect_major': return '
|
| 371 |
-
case 'effect_minor': return '
|
| 372 |
-
case 'no_effect': return '
|
| 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 |
}
|
|
@@ -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">
|
| 17 |
</button>
|
| 18 |
<nav className="nh-nav">
|
| 19 |
-
<button className={route === 'list' ? 'on' : ''} onClick={() => onNav('list')}>
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</nav>
|
| 22 |
</div>
|
| 23 |
-
{
|
| 24 |
-
|
| 25 |
-
<
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
</header>
|
| 33 |
);
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
|
|
| 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">
|
| 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}
|
|
@@ -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 |
+
};
|
|
@@ -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;
|
|
@@ -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 |
+
}
|
|
@@ -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>
|
| 31 |
<span>·</span>
|
| 32 |
-
<span className="mono">case {id.slice(0, 8)}
|
| 33 |
<span>·</span>
|
| 34 |
-
<b>
|
| 35 |
</div>
|
| 36 |
-
<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=
|
| 46 |
<p style={{ color: 'var(--oxblood)' }}>{err}</p>
|
| 47 |
</Dossier>
|
| 48 |
)}
|
| 49 |
-
{!md && !err && <p className="fg-3" style={{ padding: 20 }}><Spinner />
|
| 50 |
{md && (
|
| 51 |
-
<Dossier tag=
|
| 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>
|
|
@@ -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('
|
| 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
|
| 359 |
-
|
| 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([
|
| 376 |
try {
|
| 377 |
const r = await api.advance(id);
|
| 378 |
-
setAdvanceLog((L) => [...L,
|
| 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=
|
| 469 |
<p style={{ color: 'var(--oxblood)' }}>{err}</p>
|
| 470 |
-
<button className="btn ghost sm" onClick={() => { setErr(null); loadAll(); }}>
|
|
|
|
|
|
|
| 471 |
</Dossier>
|
| 472 |
);
|
| 473 |
}
|
| 474 |
if (!briefing) {
|
| 475 |
-
return <p className="fg-3" style={{ padding: 20 }}><Spinner />
|
| 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>
|
| 484 |
<span>·</span>
|
| 485 |
-
<span>
|
| 486 |
<span>·</span>
|
| 487 |
<b>
|
| 488 |
-
{state?.status === 'paused' ? '
|
| 489 |
<>
|
| 490 |
-
{phase === 'briefing' && '
|
| 491 |
-
{phase === 'recon' && '
|
| 492 |
-
{phase === 'action' && '
|
| 493 |
-
{phase === 'deliberation' && '
|
| 494 |
-
{phase === 'adjudication' && '
|
| 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 |
-
|
| 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={
|
| 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 }}>
|
|
|
|
|
|
|
| 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={
|
| 540 |
-
<h3 className="nh-serif-h">
|
|
|
|
|
|
|
| 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 |
-
>
|
| 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=
|
| 571 |
{reconLog.length === 0 && (
|
| 572 |
<p className="body-sm fg-3" style={{ marginTop: 10 }}>
|
| 573 |
-
|
| 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 |
-
|
| 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>
|
| 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=
|
| 606 |
/>
|
| 607 |
</div>
|
| 608 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
|
| 609 |
-
<span className="help mono">
|
| 610 |
<button
|
| 611 |
className="btn primary sm"
|
| 612 |
disabled={!reconQ.trim() || reconBusy}
|
| 613 |
onClick={askRecon}
|
| 614 |
-
>{reconBusy ? <><Spinner />
|
| 615 |
</div>
|
| 616 |
</div>
|
| 617 |
) : (
|
| 618 |
<div className="nh-recon__in">
|
| 619 |
-
<span className="help mono">
|
| 620 |
</div>
|
| 621 |
)}
|
| 622 |
</Panel>
|
| 623 |
|
| 624 |
{(agents.length > 0 || state?.agents) && (
|
| 625 |
-
<Panel tag=
|
| 626 |
<ul className="nh-agents">
|
| 627 |
-
{(agents.length > 0 ? agents : state.agents).map((a: any) =>
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 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>
|
| 661 |
{actionSent ? (
|
| 662 |
-
<span className="hint">
|
| 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 |
-
>
|
| 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 |
-
>
|
| 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">
|
| 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
|
|
|
|
|
|
|
| 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=
|
| 708 |
>
|
| 709 |
-
{pauseBusy
|
|
|
|
|
|
|
| 710 |
</button>
|
| 711 |
)}
|
| 712 |
<button className="btn primary" onClick={advance} disabled={advanceBusy}>
|
| 713 |
-
{advanceBusy
|
|
|
|
|
|
|
| 714 |
</button>
|
| 715 |
</>
|
| 716 |
)}
|
|
@@ -754,26 +787,34 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 754 |
) : (
|
| 755 |
<>
|
| 756 |
<div className="nh-formrow">
|
| 757 |
-
<label>
|
|
|
|
|
|
|
|
|
|
| 758 |
<input
|
| 759 |
value={intent} maxLength={160}
|
| 760 |
onChange={(e) => setIntent(e.target.value)}
|
| 761 |
-
placeholder=
|
| 762 |
/>
|
| 763 |
</div>
|
| 764 |
<div className="nh-formrow">
|
| 765 |
-
<label>
|
|
|
|
|
|
|
|
|
|
| 766 |
<textarea
|
| 767 |
value={why} maxLength={400}
|
| 768 |
onChange={(e) => setWhy(e.target.value)}
|
| 769 |
-
placeholder=
|
| 770 |
/>
|
| 771 |
</div>
|
| 772 |
|
| 773 |
<div className="nh-anchors">
|
| 774 |
<div className="legend">
|
| 775 |
-
<span>
|
| 776 |
-
<span className={`count ${anchorsFilled >= 3 ? 'ok' : ''}`}>
|
|
|
|
|
|
|
| 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">
|
| 794 |
-
<option value="stated_capability">
|
| 795 |
-
<option value="resource_committed">
|
| 796 |
</select>
|
| 797 |
-
<div className="hint">{
|
| 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={
|
| 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 |
-
>
|
| 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 |
-
? '
|
| 836 |
: preflightBusy
|
| 837 |
-
? '
|
| 838 |
-
:
|
| 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 ? '
|
| 847 |
-
: '
|
| 848 |
</button>
|
| 849 |
</div>
|
| 850 |
</>
|
|
@@ -873,15 +914,15 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 873 |
);
|
| 874 |
}
|
| 875 |
|
| 876 |
-
const
|
| 877 |
-
prior_analogue: '
|
| 878 |
-
stated_capability: '
|
| 879 |
-
resource_committed: '
|
| 880 |
};
|
| 881 |
-
const
|
| 882 |
-
prior_analogue: '
|
| 883 |
-
stated_capability: '
|
| 884 |
-
resource_committed: '
|
| 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 |
-
(
|
| 938 |
);
|
| 939 |
if (!resolved.length) return null;
|
| 940 |
return (
|
| 941 |
<section className="nh-verdicts">
|
| 942 |
-
<h2>
|
| 943 |
-
{resolved.map((
|
| 944 |
-
const action =
|
| 945 |
-
const verdictCount =
|
| 946 |
return (
|
| 947 |
<div className="nh-vturn" key={i}>
|
| 948 |
<div className="nh-vturn__hd">
|
| 949 |
-
<span className="mono eyebrow">
|
| 950 |
<span className="mono fg-3">
|
| 951 |
-
{verdictCount > 0
|
|
|
|
|
|
|
| 952 |
</span>
|
| 953 |
<span className="spacer" style={{ flex: 1 }} />
|
| 954 |
<button
|
| 955 |
className="btn ghost sm"
|
| 956 |
disabled={forkBusy !== null}
|
| 957 |
-
onClick={() => onFork(
|
| 958 |
-
title={
|
| 959 |
>
|
| 960 |
-
{forkBusy ===
|
|
|
|
|
|
|
| 961 |
</button>
|
| 962 |
</div>
|
| 963 |
{action && (
|
| 964 |
<div className="nh-v">
|
| 965 |
<div className="nh-v__hd">
|
| 966 |
-
<span className="actor mono">
|
| 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">
|
| 1013 |
<h2 className="nh-serif-h" style={{ margin: '4px 0 0' }}>
|
| 1014 |
{complete
|
| 1015 |
-
? '
|
| 1016 |
: running
|
| 1017 |
-
? '
|
| 1018 |
-
: '
|
| 1019 |
</h2>
|
| 1020 |
</div>
|
| 1021 |
{(complete || failed) && (
|
| 1022 |
<button className="btn ghost sm" onClick={onToggle}>
|
| 1023 |
-
{open ? '
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 ? '
|
| 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 |
-
|
| 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)' }}>
|
|
|
|
|
|
|
| 1096 |
{canRun ? (
|
| 1097 |
-
<button className="btn ghost sm" onClick={onRun}>
|
| 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">
|
| 1128 |
-
<div className="help">{b?.skip_reason || '
|
| 1129 |
</div>
|
| 1130 |
) : (
|
| 1131 |
<>
|
|
@@ -1144,7 +1186,7 @@ function OrchestratorBar({
|
|
| 1144 |
)}
|
| 1145 |
{action?.intent && (
|
| 1146 |
<div className="nh-orch__cplay">
|
| 1147 |
-
<span className="eyebrow">
|
| 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 |
-
|
| 1158 |
</button>
|
| 1159 |
)}
|
| 1160 |
</>
|
|
@@ -1165,7 +1207,11 @@ function OrchestratorBar({
|
|
| 1165 |
</div>
|
| 1166 |
{map?.synthesis_markdown && (
|
| 1167 |
<details className="nh-orch__full">
|
| 1168 |
-
<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}>
|
| 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 |
-
|
| 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=
|
| 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">{
|
| 1262 |
<button
|
| 1263 |
type="submit"
|
| 1264 |
className="btn primary"
|
| 1265 |
disabled={!input.trim() || busy}
|
| 1266 |
>
|
| 1267 |
-
{busy
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 1292 |
</span>
|
| 1293 |
<CoachDraftCard draft={draft} />
|
| 1294 |
<div className="nh-spec__fn" style={{ marginTop: 6 }}>
|
| 1295 |
-
|
| 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">
|
| 1304 |
-
<div className="hd">
|
| 1305 |
</div>
|
| 1306 |
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
| 1307 |
<button className="btn ghost sm" onClick={onSwitchToManualWithDraft} disabled={busy}>
|
| 1308 |
-
|
| 1309 |
</button>
|
| 1310 |
<button className="btn ghost sm" onClick={onReset} disabled={busy}>
|
| 1311 |
-
|
| 1312 |
</button>
|
| 1313 |
<button className="btn primary" onClick={onCommit} disabled={busy}>
|
| 1314 |
-
{busy
|
|
|
|
|
|
|
| 1315 |
</button>
|
| 1316 |
</div>
|
| 1317 |
</div>
|
|
@@ -1322,7 +1373,7 @@ function ActionCoachThread({
|
|
| 1322 |
value={input}
|
| 1323 |
rows={4}
|
| 1324 |
maxLength={4000}
|
| 1325 |
-
placeholder=
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
| 1337 |
</span>
|
| 1338 |
<button
|
| 1339 |
type="submit"
|
| 1340 |
className="btn primary"
|
| 1341 |
disabled={!input.trim() || busy}
|
| 1342 |
>
|
| 1343 |
-
{busy
|
|
|
|
|
|
|
| 1344 |
</button>
|
| 1345 |
</div>
|
| 1346 |
</form>
|
| 1347 |
<div style={{ marginTop: 8 }}>
|
| 1348 |
<button className="btn ghost sm" onClick={onReset} disabled={busy}>
|
| 1349 |
-
|
| 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' ? '
|
| 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 }}>
|
| 1386 |
<div style={{ fontWeight: 600 }}>{draft.intent}</div>
|
| 1387 |
</div>
|
| 1388 |
<div>
|
| 1389 |
-
<div className="eyebrow" style={{ marginBottom: 2 }}>
|
| 1390 |
<div>{draft.justification}</div>
|
| 1391 |
</div>
|
| 1392 |
<div>
|
| 1393 |
-
<div className="eyebrow" style={{ marginBottom: 4 }}>
|
|
|
|
|
|
|
| 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">
|
| 1472 |
<div className="hd">
|
| 1473 |
{constraints.length === 0
|
| 1474 |
-
? '
|
| 1475 |
-
:
|
|
|
|
|
|
|
| 1476 |
</div>
|
| 1477 |
</div>
|
| 1478 |
-
<span className="mono fg-3">
|
|
|
|
|
|
|
| 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 |
-
|
| 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>
|
|
|
|
|
|
|
|
|
|
| 1501 |
<textarea
|
| 1502 |
value={draft.description}
|
| 1503 |
maxLength={400}
|
| 1504 |
rows={2}
|
| 1505 |
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
| 1506 |
-
placeholder=
|
| 1507 |
/>
|
| 1508 |
</div>
|
| 1509 |
<div className="nh-formrow">
|
| 1510 |
-
<label>
|
|
|
|
|
|
|
|
|
|
| 1511 |
<textarea
|
| 1512 |
value={draft.rationale}
|
| 1513 |
maxLength={400}
|
| 1514 |
rows={2}
|
| 1515 |
onChange={(e) => setDraft({ ...draft, rationale: e.target.value })}
|
| 1516 |
-
placeholder=
|
| 1517 |
/>
|
| 1518 |
</div>
|
| 1519 |
<div className="nh-formrow">
|
| 1520 |
-
<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='
|
| 1527 |
/>
|
| 1528 |
</div>
|
| 1529 |
<div className="nh-formrow">
|
| 1530 |
-
<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">
|
| 1537 |
-
<option value="soft">
|
| 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 |
-
>
|
| 1548 |
<button
|
| 1549 |
type="button"
|
| 1550 |
className="btn primary"
|
| 1551 |
onClick={submit}
|
| 1552 |
disabled={!draftValid || busy}
|
| 1553 |
>
|
| 1554 |
-
{busy
|
|
|
|
|
|
|
| 1555 |
</button>
|
| 1556 |
</div>
|
| 1557 |
</div>
|
| 1558 |
) : (
|
| 1559 |
<div className="nh-constraints__addbar">
|
| 1560 |
<span className="hint mono fg-3">
|
| 1561 |
-
|
| 1562 |
</span>
|
| 1563 |
<button type="button" className="btn ghost sm" onClick={() => setAdding(true)}>
|
| 1564 |
-
|
| 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
|
|
|
|
|
|
|
| 1593 |
>
|
| 1594 |
{display}
|
| 1595 |
</button>
|
|
@@ -1597,17 +1675,23 @@ function ConstraintRow({
|
|
| 1597 |
type="button"
|
| 1598 |
className="nh-constraint__rm"
|
| 1599 |
onClick={onDelete}
|
| 1600 |
-
title=
|
| 1601 |
>
|
| 1602 |
-
remove
|
| 1603 |
</button>
|
| 1604 |
</div>
|
| 1605 |
{expanded && (
|
| 1606 |
<div className="nh-constraint__detail">
|
| 1607 |
-
<div><span className="eyebrow">
|
| 1608 |
-
<div>
|
|
|
|
|
|
|
|
|
|
| 1609 |
{c.source_ref && (
|
| 1610 |
-
<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">
|
| 1628 |
-
<button type="button" className="nh-violations__rm" onClick={onDismiss}>
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
);
|
|
@@ -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>
|
| 23 |
-
<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>
|
| 31 |
<div className="nh-tablehd__r">
|
| 32 |
-
<button className="btn ghost sm" onClick={() => navigate('/new')}>
|
|
|
|
|
|
|
| 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 }}>
|
| 38 |
{games && games.length === 0 && (
|
| 39 |
<div className="nh-dossier">
|
| 40 |
-
<p>
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
)}
|
| 43 |
|
|
@@ -45,18 +50,21 @@ export function GamesList() {
|
|
| 45 |
<table className="nh-games">
|
| 46 |
<thead>
|
| 47 |
<tr>
|
| 48 |
-
<th>
|
| 49 |
-
<th>
|
| 50 |
-
<th>
|
| 51 |
-
<th>
|
| 52 |
-
<th>
|
| 53 |
-
<th className="rt">
|
| 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 ?
|
| 68 |
</td>
|
| 69 |
<td className="mono">{g.turn} / {g.max_turns}</td>
|
| 70 |
<td>
|
| 71 |
-
<span className={`nh-chip ${g.status}`}>{
|
| 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>
|
|
@@ -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
|
| 10 |
-
control: '
|
| 11 |
-
agent: '
|
| 12 |
-
rival: '
|
| 13 |
-
critic: '
|
| 14 |
-
premortem: '
|
| 15 |
-
evaluator: '
|
| 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>
|
| 72 |
-
<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">
|
| 82 |
-
<div className="hd">
|
| 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">
|
| 89 |
</div>
|
| 90 |
|
| 91 |
{err && <div className="nh-dossier"><p style={{ color: 'var(--oxblood)' }}>{err}</p></div>}
|
| 92 |
-
{!scenarios && !err && <p className="fg-3">
|
| 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>
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
</button>
|
| 112 |
))}
|
| 113 |
</div>
|
| 114 |
)}
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<div className="nh-create">
|
| 117 |
<div>
|
| 118 |
-
<span className="eyebrow">
|
| 119 |
-
<div className="hd">{
|
| 120 |
<p className="fg-3">
|
| 121 |
{models.length > 0 ? (
|
| 122 |
<>
|
| 123 |
-
|
| 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 |
-
{' '}
|
| 130 |
</>
|
| 131 |
) : (
|
| 132 |
-
<>
|
| 133 |
)}
|
| 134 |
</p>
|
| 135 |
</div>
|
| 136 |
<button className="btn primary" onClick={create} disabled={!picked || busy}>
|
| 137 |
-
{busy ? '
|
| 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">
|
| 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">
|
|
|
|
|
|
|
| 187 |
</button>
|
| 188 |
{open && (
|
| 189 |
<div className="nh-role-overrides__body">
|
| 190 |
<p className="fg-3" style={{ margin: '0 0 12px' }}>
|
| 191 |
-
|
| 192 |
-
<span className="mono">{gameDefault ||
|
| 193 |
-
|
| 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 |
-
{
|
| 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="">(
|
| 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 |
-
|
| 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>
|
|
@@ -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(
|
| 96 |
return;
|
| 97 |
}
|
| 98 |
setDocBusy(true);
|
| 99 |
try {
|
| 100 |
for (const file of list) {
|
| 101 |
if (!isAllowedExt(file.name)) {
|
| 102 |
-
setDocErr(
|
| 103 |
continue;
|
| 104 |
}
|
| 105 |
if (file.size > DOC_MAX_BYTES) {
|
| 106 |
-
setDocErr(
|
| 107 |
continue;
|
| 108 |
}
|
| 109 |
try {
|
| 110 |
await api.uploadDocument(session.session_id, file);
|
| 111 |
} catch (e: any) {
|
| 112 |
-
setDocErr(
|
| 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(
|
| 129 |
}
|
| 130 |
}
|
| 131 |
|
|
@@ -153,13 +155,9 @@ export function SetupCase() {
|
|
| 153 |
return (
|
| 154 |
<>
|
| 155 |
<section className="nh-hero">
|
| 156 |
-
<div className="nh-eyebrow"><b>
|
| 157 |
-
<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&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 |
-
|
| 174 |
-
<span className="ct mono">{scenarioId.length
|
| 175 |
</label>
|
| 176 |
<input
|
| 177 |
value={scenarioId}
|
| 178 |
maxLength={60}
|
| 179 |
onChange={(e) => setScenarioId(e.target.value)}
|
| 180 |
-
placeholder=
|
| 181 |
style={{ fontFamily: 'var(--font-mono)' }}
|
| 182 |
/>
|
| 183 |
<div className="hint" style={{ marginTop: 4, color: 'var(--slate)' }}>
|
| 184 |
-
|
| 185 |
{!scenarioIdValid && scenarioId.length > 0 && (
|
| 186 |
-
<span style={{ color: 'var(--oxblood)' }}>
|
| 187 |
)}
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
<div className="nh-formrow" style={{ marginTop: 14 }}>
|
| 191 |
<label>
|
| 192 |
-
|
| 193 |
-
<span className="ct mono">{brief.trim().length
|
| 194 |
</label>
|
| 195 |
<textarea
|
| 196 |
value={brief}
|
| 197 |
maxLength={2000}
|
| 198 |
rows={8}
|
| 199 |
onChange={(e) => setBrief(e.target.value)}
|
| 200 |
-
placeholder=
|
| 201 |
/>
|
| 202 |
</div>
|
| 203 |
<div className="nh-chat__submit">
|
| 204 |
-
<span className="hint">
|
| 205 |
<button
|
| 206 |
type="submit"
|
| 207 |
className="btn primary"
|
| 208 |
disabled={!scenarioIdValid || !briefValid || busy}
|
| 209 |
>
|
| 210 |
-
{busy ? <><Spinner />
|
| 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>
|
|
|
|
| 230 |
<span>·</span>
|
| 231 |
-
<b>{ready ? '
|
| 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">
|
|
|
|
|
|
|
| 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">
|
| 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 |
-
|
| 272 |
-
|
|
|
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
</div>
|
| 284 |
<div className="hint mono fg-3" style={{ marginTop: 6 }}>
|
| 285 |
-
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
) : (
|
| 289 |
<div className="nh-chat__approve">
|
| 290 |
<div>
|
| 291 |
-
<span className="eyebrow">
|
| 292 |
-
<div className="hd">
|
| 293 |
</div>
|
| 294 |
<button className="btn primary" onClick={commitAndLaunch} disabled={busy}>
|
| 295 |
-
|
| 296 |
</button>
|
| 297 |
</div>
|
| 298 |
)
|
|
@@ -310,17 +314,17 @@ export function SetupCase() {
|
|
| 310 |
send();
|
| 311 |
}
|
| 312 |
}}
|
| 313 |
-
placeholder=
|
| 314 |
disabled={busy}
|
| 315 |
/>
|
| 316 |
<div className="nh-chat__submit">
|
| 317 |
-
<span className="hint">{answer.length
|
| 318 |
<button
|
| 319 |
type="submit"
|
| 320 |
className="btn primary"
|
| 321 |
disabled={!answer.trim() || busy}
|
| 322 |
>
|
| 323 |
-
{busy ? <><Spinner />
|
| 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
|
| 338 |
return (
|
| 339 |
<div className={`nh-msg ${who}`}>
|
| 340 |
-
<div className="nh-msg__meta"><span>{
|
| 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">
|
| 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
|
| 384 |
</div>
|
| 385 |
|
| 386 |
<div
|
|
@@ -425,13 +429,13 @@ function DocUploadPanel({
|
|
| 425 |
/>
|
| 426 |
<div className="nh-doc-drop__cta">
|
| 427 |
{busy ? (
|
| 428 |
-
<><Spinner />
|
| 429 |
) : remaining <= 0 ? (
|
| 430 |
-
<span>
|
| 431 |
) : (
|
| 432 |
<>
|
| 433 |
-
<strong>
|
| 434 |
-
<span className="mono"> {DOC_ALLOWED_EXT.join(' · ')}</span> ·
|
| 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 |
))}
|
|
@@ -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=
|
| 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,
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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"(
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
# ---------------------------------------------------------------------------
|
|
@@ -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 |
|
|
@@ -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):
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
| 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",
|
|
@@ -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(
|
|
@@ -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 |
-
|
| 175 |
-
|
| 176 |
-
|
| 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.
|
|
@@ -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,
|
|
@@ -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…",
|
|
@@ -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 |
# ---------------------------------------------------------------------------
|
|
@@ -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"
|
|
@@ -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")
|