// Minimal typed client for the AI War Gaming FastAPI backend. const BASE = (import.meta as any).env?.VITE_API_BASE ?? '/api'; // Trial password gate: backend may require an X-Trial-Password header. // We cache the password in localStorage and re-prompt on a 401. // Local dev has no gate so this is a no-op there. const PASSWORD_KEY = 'wargame_trial_password'; function getPassword(): string | null { try { return localStorage.getItem(PASSWORD_KEY); } catch { return null; } } function setPassword(value: string | null): void { try { if (value === null) localStorage.removeItem(PASSWORD_KEY); else localStorage.setItem(PASSWORD_KEY, value); } catch { /* private mode etc. — swallow */ } } function promptForPassword(): string | null { if (typeof window === 'undefined' || typeof window.prompt !== 'function') return null; const value = window.prompt('Enter trial access password'); return value && value.length > 0 ? value : null; } async function doFetch(path: string, init: RequestInit | undefined, password: string | null): Promise { const headers: Record = { 'Content-Type': 'application/json', ...((init?.headers as Record | undefined) || {}), }; if (password) headers['X-Trial-Password'] = password; return fetch(`${BASE}${path}`, { ...init, headers }); } async function req(path: string, init?: RequestInit): Promise { let res = await doFetch(path, init, getPassword()); if (res.status === 401) { setPassword(null); const next = promptForPassword(); if (next) { setPassword(next); res = await doFetch(path, init, next); } } if (!res.ok) { const body = await res.text().catch(() => ''); throw Object.assign(new Error(`${res.status} ${res.statusText}: ${body}`), { status: res.status }); } return res.status === 204 ? (undefined as any) : res.json(); } export const api = { health: () => req<{ status: string }>('/healthz'), listScenarios: () => req<{ scenarios: Scenario[] }>('/scenarios'), listGames: () => req<{ games: GameSummary[] }>('/games'), listModels: () => req('/models'), listAgents: (scenario_id: string) => req(`/scenarios/${scenario_id}/agents`), createGame: ( scenario_id: string, model?: string, role_model_overrides?: Record, language?: 'en' | 'de', ) => { const body: Record = { scenario_id }; if (model) body.model = model; if (role_model_overrides && Object.keys(role_model_overrides).length > 0) { body.role_model_overrides = role_model_overrides; } // ---- i18n (round 6) ---- if (language) body.language = language; return req<{ game_id: string; turn: number; briefing: Briefing }>(`/games`, { method: 'POST', body: JSON.stringify(body), }); }, getState: (id: string) => req(`/games/${id}/state`), getBriefing: (id: string) => req(`/games/${id}/briefing`), recon: (id: string, agent_id: string, question: string) => req(`/games/${id}/recon`, { method: 'POST', body: JSON.stringify({ agent_id, question }), }), submitAction: (id: string, action: ActionRequest) => req(`/games/${id}/actions`, { method: 'POST', body: JSON.stringify(action), }), advance: (id: string) => req(`/games/${id}/advance`, { method: 'POST' }), debrief: (id: string) => req(`/games/${id}/debrief`), metrics: (id: string) => req(`/games/${id}/metrics/timeline`), setupStart: (scenario_id: string, initial_message: string) => req(`/scenarios/setup`, { method: 'POST', body: JSON.stringify({ scenario_id, initial_message }), }), setupGet: (session_id: string) => req(`/scenarios/setup/${session_id}`), setupAnswer: (session_id: string, message: string) => req(`/scenarios/setup/${session_id}/answer`, { method: 'POST', body: JSON.stringify({ message }), }), setupCommit: (session_id: string, force = false) => req(`/scenarios/setup/${session_id}/commit`, { method: 'POST', body: JSON.stringify({ force }), }), fork: (game_id: string, from_turn: number) => req(`/games/${game_id}/fork`, { method: 'POST', body: JSON.stringify({ from_turn }), }), orchestrate: (game_id: string, opts?: { branches?: number; turns?: number }) => req(`/games/${game_id}/orchestrate`, { method: 'POST', body: JSON.stringify({ branches: opts?.branches ?? 3, turns: opts?.turns ?? 1 }), }), orchestrateStatus: (game_id: string) => req(`/games/${game_id}/orchestrate/status`), orchestrateResult: (game_id: string) => req(`/games/${game_id}/orchestrate/result`), // Action Coach — conversational action input that drafts a 3-anchor // ActionSubmission and submits it through the same engine path. actionCoachStart: (game_id: string, initial_prose: string) => req(`/games/${game_id}/actions/coach`, { method: 'POST', body: JSON.stringify({ initial_prose }), }), actionCoachLoad: (game_id: string, session_id: string) => req(`/games/${game_id}/actions/coach/${session_id}`), actionCoachAnswer: (game_id: string, session_id: string, message: string) => req(`/games/${game_id}/actions/coach/${session_id}/answer`, { method: 'POST', body: JSON.stringify({ message }), }), actionCoachCommit: (game_id: string, session_id: string) => req(`/games/${game_id}/actions/coach/${session_id}/commit`, { method: 'POST', }), // ---- forms (5b) ---- addConstraint: (game_id: string, body: HardConstraintInput) => req(`/games/${game_id}/constraints`, { method: 'POST', body: JSON.stringify(body), }), checkAction: (game_id: string, action: ActionRequest) => req(`/games/${game_id}/check_action`, { method: 'POST', body: JSON.stringify(action), }), deleteConstraint: (game_id: string, constraint_id: string, force = false) => req( `/games/${game_id}/constraints/${encodeURIComponent(constraint_id)}${force ? '?force=true' : ''}`, { method: 'DELETE' }, ), deleteDocument: (session_id: string, filename: string) => req( `/scenarios/setup/${session_id}/documents/${encodeURIComponent(filename)}`, { method: 'DELETE' }, ), listConstraints: (game_id: string) => req(`/games/${game_id}/constraints`), listDocuments: (session_id: string) => req(`/scenarios/setup/${session_id}/documents`), uploadDocument: (session_id: string, file: File) => { const fd = new FormData(); fd.append('file', file); // NOTE: don't set Content-Type — the browser fills in the multipart // boundary. `req()` defaults to JSON, so we call fetch directly here. return fetch(`${BASE}/scenarios/setup/${session_id}/documents`, { method: 'POST', body: fd, }).then(async (res) => { if (!res.ok) { const body = await res.text().catch(() => ''); throw Object.assign( new Error(`${res.status} ${res.statusText}: ${body}`), { status: res.status }, ); } return res.json() as Promise; }); }, // ---- live (5b) ---- pauseGame: (id: string) => req(`/games/${id}/pause`, { method: 'POST' }), resumeGame: (id: string) => req(`/games/${id}/resume`, { method: 'POST' }), // EventSource can't attach custom headers, so the trial password rides in // a `?token=` query param instead. The backend middleware accepts either. streamUrl: (id: string) => { const pw = getPassword(); const qs = pw ? `?token=${encodeURIComponent(pw)}` : ''; return `${BASE}/games/${id}/stream${qs}`; }, }; export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed'; export interface Anchor { kind: AnchorKind; detail: string } export interface ActionRequest { intent: string; justification: string; anchors: Anchor[] } export interface ActionAcceptedResponse { game_id: string; turn: number; action: any } export interface Scenario { scenario_id: string; company_name: string; rival_name: string; industry: string; player_role: string; agent_count: number } export interface GameSummary { id: string; scenario_id: string; turn: number; max_turns: number; status: 'active' | 'complete'; updated_at: string; llm_model?: string | null } export interface ModelInfo { id: string; display_name: string; provider: string } export interface ModelsResponse { models: ModelInfo[]; default: string; roles?: string[] } export interface AgentInfo { id: string; label: string; kind: string; posture: string } export interface AgentsResponse { scenario_id: string; agents: AgentInfo[] } export interface BriefingSection { heading: string; body: string; fog_of_war?: string | null } export interface DecisionPoint { title: string; context: string; example_actions: string[] } export interface Briefing { turn: number; headline: string; exec_summary: string; sections: BriefingSection[]; decision_points: DecisionPoint[] } export interface BriefingResponse { game_id: string; turn: number; briefing: Briefing; recon_budget_remaining: number } export interface ReconResponseEnvelope { game_id: string; recon_budget_remaining: number; response: { answered_by: string; question: string; answer: string; reveals_to_rival: boolean } } export interface AdvanceResponse { game_id: string; status: string; turn_resolved: number; next_turn?: number; briefing?: Briefing } export interface DebriefResponse { game_id: string; status: string; markdown: string } export interface MetricsTimelineResponse { game_id: string; points: { turn: number; metrics: { key: string; label: string; unit: string; value: number; delta: number }[] }[] } export interface StateResponse { id: string; scenario_id: string; status: string; world: any; recon_budget_remaining: number; current_phase: string; turns: any[]; language?: 'en' | 'de' } export interface SetupMessage { role: 'user' | 'setup_agent'; content: string; at: string } export interface SetupSession { session_id: string; scenario_id: string; status: 'gathering' | 'ready' | 'committed'; last_agent_message: string; synthesized_brief: string | null; committed_scenario_id: string | null; messages: SetupMessage[]; doc_filenames?: string[]; } export interface ScaffoldResponse { scenario_id: string; target_dir: string; mode: 'blank' | 'generated'; summary: Scenario | null; } export interface ForkResponse { game_id: string; forked_from: string; forked_at_turn: number; turn: number; briefing: Briefing; } export type OrchestratorStatus = 'pending' | 'running' | 'complete' | 'failed'; export interface StrategyVector { id: string; label: string; description: string; play_style_note: string; likely_stumbles: string[]; priority_metrics: string[]; } export interface OrchestrateAccepted { map_id: string; status: string } export interface OrchestrateStatus { map_id: string; status: OrchestratorStatus; branches_complete: number; branches_total: number; error: string | null; } export interface ScenarioMap { id: string; source_game_id: string; scenario_id: string; created_at: string; updated_at: string; vectors: StrategyVector[]; branches: Array<{ vector_id: string; branch_game_id: string; proxy_temperature: number; narrative_after?: string; skipped?: boolean; skip_reason?: string | null; turns_played?: number; }>; synthesis_markdown: string; branches_requested: number; turns_requested: number; status: OrchestratorStatus; branches_complete: number; error: string | null; } // Action Coach — conversational action input. export interface ActionDraft { intent: string; justification: string; anchors: Anchor[]; } export interface CoachMessage { role: 'user' | 'coach'; content: string; at: string; } export type ActionCoachStatus = 'gathering' | 'ready' | 'committed'; export interface ActionCoachSession { session_id: string; game_id: string; turn: number; status: ActionCoachStatus; rounds_used: number; last_coach_message: string; synthesized_draft: ActionDraft | null; committed: boolean; messages: CoachMessage[]; } // ---- forms (5b) ---- // Hard constraints + uploaded documents + check-action shapes (Round 4 backend). export type ConstraintSeverity = 'hard' | 'soft'; export type ConstraintSource = 'scenario' | 'document' | 'manual'; export interface HardConstraint { id: string; description: string; rationale: string; test_question: string; severity: ConstraintSeverity; source: ConstraintSource; source_ref?: string | null; } export interface HardConstraintInput { description: string; rationale: string; test_question: string; severity?: ConstraintSeverity; source_ref?: string | null; } export interface HardConstraintListResponse { game_id: string; constraints: HardConstraint[]; } export interface ConstraintViolation { constraint_id: string; constraint_description: string; reason: string; } export interface ConstraintCheckResult { passes: boolean; violations: ConstraintViolation[]; } export interface ConstraintCheckResponse { game_id: string; result: ConstraintCheckResult; } export interface UploadedDocument { filename: string; bytes_stored: number; } export interface DocumentListResponse { session_id: string; documents: UploadedDocument[]; } // ---- live (5b) ---- // PhaseEvent — server-sent events that fire on the engine bus during an // advance. Discriminated by `kind`. Wire format documented in CONTRACT.md. export type PhaseEventKind = | 'phase_started' | 'phase_completed' | 'deliberation_complete' | 'critic_complete' | 'premortem_complete' | 'verdict_complete' | 'evaluator_complete' | 'world_updated' | 'briefing_ready' | 'usage_tick' | 'paused' | 'resumed' | 'error'; export type VerdictOutcome = | 'effect_major' | 'effect_minor' | 'no_effect' | 'backfires'; interface PhaseEventBase { game_id: string; turn: number; label: string; at: string; payload: Record & { dropped?: number }; } export type PhaseEvent = | (PhaseEventBase & { kind: 'phase_started'; payload: { dropped?: number } }) | (PhaseEventBase & { kind: 'phase_completed'; payload: { dropped?: number } }) | (PhaseEventBase & { kind: 'deliberation_complete'; payload: { agent_id: string; expects: string; fears: string; dropped?: number }; }) | (PhaseEventBase & { kind: 'critic_complete'; payload: { count: number; issue_count: number; dropped?: number }; }) | (PhaseEventBase & { kind: 'premortem_complete'; payload: { count: number; dropped?: number }; }) | (PhaseEventBase & { kind: 'verdict_complete'; payload: { actor: string; plausibility: number; outcome: VerdictOutcome; dropped?: number; }; }) | (PhaseEventBase & { kind: 'evaluator_complete'; payload: { scored: number; dropped?: number }; }) | (PhaseEventBase & { kind: 'world_updated'; payload: { media_tone: string; public_narrative: string; dropped?: number }; }) | (PhaseEventBase & { kind: 'briefing_ready'; payload: { turn: number; dropped?: number }; }) | (PhaseEventBase & { kind: 'usage_tick'; payload: { cost_usd_so_far: number; input_tokens_so_far: number; output_tokens_so_far: number; dropped?: number; }; }) | (PhaseEventBase & { kind: 'paused'; payload: { turn: number; dropped?: number }; }) | (PhaseEventBase & { kind: 'resumed'; payload: { dropped?: number }; }) | (PhaseEventBase & { kind: 'error'; payload: Record & { dropped?: number }; });