From 33a824ffcbd311897255e83d2335d9bb4d0c9ca7 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 20 Aug 2026 08:03:29 -0400 Subject: [PATCH] feat: add simultaneous tick execution --- README.md | 53 +- ROADMAP.md | 12 + apps/game-api/src/app.test.ts | 120 +++- apps/game-api/src/app.ts | 63 +- apps/game-api/src/experiment-export.ts | 113 +++- apps/game-api/src/simulation-service.test.ts | 408 ++++++++++- apps/game-api/src/simulation-service.ts | 337 ++++++++- .../src/components/world-lab.test.tsx | 447 ++++++------ apps/world-lab/src/components/world-lab.tsx | 639 ++++++------------ docs/ARCHITECTURE.md | 118 +++- docs/EXPERIMENT_ARCHIVE.md | 10 +- docs/GAMEPLAY_FOUNDATION.md | 16 +- docs/SECURITY.md | 29 +- docs/TESTING.md | 39 +- docs/adr/0014-simultaneous-tick-authority.md | 57 ++ .../adr/0015-selective-agent-communication.md | 50 ++ packages/agent-runtime/src/index.test.ts | 45 ++ packages/agent-runtime/src/index.ts | 5 +- .../agent-runtime/src/tick-dispatcher.test.ts | 271 ++++++++ packages/agent-runtime/src/tick-dispatcher.ts | 335 +++++++++ .../experiment-archive/src/archive.test.ts | 90 ++- packages/experiment-archive/src/importer.ts | 20 +- packages/experiment-archive/src/migrations.ts | 11 + .../experiment-archive/src/query-service.ts | 38 +- packages/shared/src/index.test.ts | 105 ++- packages/shared/src/index.ts | 406 +++++++++-- packages/shared/src/limits.ts | 2 + packages/shared/src/scenario.test.ts | 33 + packages/world-engine/src/index.test.ts | 58 +- packages/world-engine/src/index.ts | 55 +- packages/world-engine/src/scenario.test.ts | 9 +- tests/e2e/world-lab.spec.ts | 92 +-- 32 files changed, 3226 insertions(+), 860 deletions(-) create mode 100644 docs/adr/0014-simultaneous-tick-authority.md create mode 100644 docs/adr/0015-selective-agent-communication.md create mode 100644 packages/agent-runtime/src/tick-dispatcher.test.ts create mode 100644 packages/agent-runtime/src/tick-dispatcher.ts diff --git a/README.md b/README.md index 50a0c52..0560619 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ Scenarios may optionally designate one experimental Patient Zero coordinator. The role receives bounded global strategic information and can send private advisory directives, but remains subject to the same movement and world-action rules as every other agent. The universal flat provider contract is -`text-flat-json-v3`; the objective remains `durable-influence-v2`. +`text-flat-json-v4`; the objective remains `durable-influence-v2`. For every +agent, including Patient Zero, no message is the normal choice unless a message +adds new decision-relevant value; routine action narration and filler are +explicitly discouraged. ## Workspace @@ -37,9 +40,27 @@ experiment archive path. Open the World Lab at . The Game API binds to ; Next.js narrowly proxies `/api/game/*` to it. `OPENROUTER_API_KEY` is the only required OpenRouter environment value. Select a compatible global model in World Lab, then optionally override individual agents. Each assignment may use the provider's default reasoning behavior, disable optional reasoning, or select only an effort advertised by that model's catalog metadata. -Each Single turn or completed playback interval makes one third-party OpenRouter request and may incur cost. Start is deliberately disabled when the server has no key. This development API has no authentication or cost controls and is not suitable for an unauthenticated public deployment. - -State is held only in the Game API process. The API captures one active safe experiment with up to 5,000 complete turn records while the browser snapshot remains capped at 120. Schema-v8 exports add the behavior registry, seed, assignments, and retained-turn attribution without changing schema-v7 attempt fields. Model and reasoning-profile assignments may be changed between requests; behavior locks after turn one. Schema-v5 through v7 imports remain supported with documented safe defaults. A saved slug absent from the current compatible catalog is preserved and blocks execution until explicitly replaced. Every provider decision is one plain-text response containing a required flat JSON object with one world action, at most one communication, and at most one formal diplomacy intent. The runtime extracts and conservatively repairs JSON before strict local schemas and the world engine apply authoritative validation. +Each Single tick or completed playback interval requests every active agent and +may incur an initial third-party OpenRouter charge plus at most one in-deadline +repair or transient-retry charge per agent. All agents observe the +same frozen pre-tick world; valid decisions resolve together while an individual +provider failure is retained as that agent's final lost tick. Start is +deliberately disabled when the server has no key. This development API has no +authentication or cost controls and is not suitable for an unauthenticated +public deployment. + +State is held only in the Game API process. The API captures one active safe +experiment with bounded complete tick groups while the browser snapshot remains +bounded without splitting a tick. Schema-v10 exports add tick number, resolution +position, virtual time, interval, and lost-tick attribution; schema-v9 archive +imports remain supported. Model and reasoning-profile assignments may be changed +between ticks; behavior locks after tick one. A saved slug absent from the +current compatible catalog is preserved and blocks execution until explicitly +replaced. Every provider decision is one plain-text response containing a +required flat JSON object with one world action, at most one communication, and +at most one formal diplomacy intent. The runtime extracts and conservatively +repairs JSON before strict local schemas and the world engine apply authoritative +validation. Export previews report exact serialized UTF-8 bytes and a model-agnostic `ceil(bytes / 4)` approximate AI-input-token estimate. Compact JSON is the default for AI sharing; Pretty JSON remains available for human review, and preview estimates reflect the selected serialization. This is a sharing-budget aid, not tokenizer output or a billing guarantee. Exports exclude fixed prompts, raw provider payloads, credentials, authorization headers, private reasoning, and unbounded diagnostics. @@ -59,17 +80,31 @@ The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resol Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, restartable world persistence, and autonomous scheduling remain deferred. -When every development cell is infected, World Lab automatically pauses playback and disables Start to avoid accidental provider calls. Reset and export remain available, and Single turn remains an explicitly manual diagnostic action. +When every development cell is infected, World Lab automatically pauses +playback and disables Start to avoid accidental provider calls. Reset and export +remain available, and Single tick remains an explicitly manual diagnostic +action. -World Lab provides browser-owned absolute run targets of **25, 50, 100, 200, 500, and 1,000**. The session-selected target defaults to 200, past/current targets are unavailable, and a bounded run pauses at the authoritative completed-turn target unless cancellation, failure, or full infection stops it first. +World Lab provides browser-owned absolute tick targets of **5, 10, 25, 50, +and 100**. The session-selected target defaults to 25. A bounded run pauses at +the authoritative tick target, on cancellation, or when the world is fully +infected. There is no background scheduler. -The persistent operator shell keeps execution controls, run target, playback speed, current turn, known cost, and run state visible while switching between Live and Agents workspaces. Live centers the map between an independently scrolling agent rail and semantic Scoreboard, Agent, Hex, and Run inspector tabs; a bounded activity dock separates public chat, events, and safe failure/recovery records. Agent configuration uses the same mounted execution controller and existing server-authoritative mutations, so workspace switching cannot duplicate or interrupt playback. Infrequent and destructive operations remain in the accessible overflow menu. Blackberry/teal/mint/celadon/vanilla semantic tokens define the dark application chrome without replacing domain-owned agent and alliance colors. +The persistent operator shell keeps execution controls, run target, playback speed, current tick, known cost, and run state visible while switching between Live and Agents workspaces. Live centers the map between an independently scrolling agent rail and semantic Scoreboard, Agent, Hex, and Run inspector tabs; a bounded activity dock separates public chat, events, and safe failure/recovery records. Agent configuration uses the same mounted execution controller and existing server-authoritative mutations, so workspace switching cannot duplicate or interrupt playback. Infrequent and destructive operations remain in the accessible overflow menu. Blackberry/teal/mint/celadon/vanilla semantic tokens define the dark application chrome without replacing domain-owned agent and alliance colors. -The agent roster defaults to browser-local **Follow turn** behavior: the inspector follows the active request, or the next scheduled agent while paused. Selecting an agent manually disables following without hiding the roster's textual Acting/Next marker; the preference remains in that browser and is never exported. Public world chat and the event log are newest-first bounded feeds, and the shared Model and Export dialogs keep their headers/actions fixed while their bodies scroll within the viewport. +The agent roster defaults to browser-local **Follow latest** behavior: after a +tick the inspector follows the last record in deterministic resolution order. +Selecting an agent manually disables following without hiding the roster's +textual Latest marker; the preference remains in that browser and is never +exported. Public world chat and the event log are newest-first bounded feeds, +and the shared Model and Export dialogs keep their headers/actions fixed while +their bodies scroll within the viewport. See [Testing](docs/TESTING.md), [Architecture](docs/ARCHITECTURE.md), [Security](docs/SECURITY.md), the accepted future [Gameplay Foundation](docs/GAMEPLAY_FOUNDATION.md), and the [Roadmap](ROADMAP.md). -Completed schema-v9 exports can be imported into an ignored local SQLite archive and queried without repeatedly loading full JSON artifacts. See [Local experiment archive](docs/EXPERIMENT_ARCHIVE.md). +Completed schema-v10 exports and legacy schema-v9 exports can be imported into +an ignored local SQLite archive and queried without repeatedly loading full JSON +artifacts. See [Local experiment archive](docs/EXPERIMENT_ARCHIVE.md). ## Rename compatibility diff --git a/ROADMAP.md b/ROADMAP.md index 01607b9..e3ace24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,6 +73,18 @@ Eleventh focused slice: redesign World Lab as a persistent long-running experime Twelfth focused slice: add scenario-owned physical communication range, private alliance communications, bounded nearby awareness, deterministic legal-move ordering variety, neutral unaffiliated presentation, and operator-only private-communication observability without adding world actions or player mechanics. +## Pre-PR 5 — Simultaneous tick experiment foundation + +Replace sequential agent turns with operator-driven simultaneous ticks. Every +active agent observes one frozen pre-tick snapshot, provider jobs dispatch +concurrently under a shared deadline, and the engine resolves a seeded order in +world-action, communication, and diplomacy phases before one proposal-expiry +pass. A deterministic virtual clock advances 5–10 configurable minutes per +committed tick. Individual provider failures become final attributed lost ticks; +cancellation commits nothing. Schema-v10 exports and the local archive retain +explicit tick attribution and complete tick groups. Background scheduling, +restart persistence, simulated players, threats, and Player Mode remain deferred. + ## PR 5 — Goals and memory Pre-PR-5 observability slice: add a local append-only SQLite experiment archive, transactional schema-v9 export import, bounded human/Codex queries, normalized comparisons, and FTS-searchable curated notes. The in-memory engine remains authoritative. Crash recovery, restartable simulation state, MCP, embeddings, vector search, and a database browser remain deferred. diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 60af0a1..4c46481 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -18,6 +18,7 @@ import { restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, + singleTickResponseSchema, updateAgentPersonalityResponseSchema, updateExperimentModelsResponseSchema, verifyModelResponseSchema, @@ -63,6 +64,118 @@ describe('provider environment compatibility', () => { }); describe('game API simulation boundary', () => { + it('coalesces repeated delivery of the same tick mutation ID', async () => { + let calls = 0; + const app = createApp({ + provider: { + mode: 'scripted-test', + configured: true, + async decide(): Promise { + calls += 1; + await Promise.resolve(); + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'tick-idempotency', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }, + }); + const request = () => + app.request('/api/simulation/tick?mutationId=tick_same_001', { + method: 'POST', + }); + const [first, second] = await Promise.all([request(), request()]); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(calls).toBe(8); + expect( + simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ).tickNumber, + ).toBe(1); + }); + + it('replays the original complete tick envelope after later ticks commit', async () => { + let calls = 0; + const app = createApp({ + provider: { + mode: 'scripted-test', + configured: true, + async decide(): Promise { + calls += 1; + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'tick-replay', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }, + }); + const first = singleTickResponseSchema.parse( + await ( + await app.request('/api/simulation/tick?mutationId=tick_replay_A', { + method: 'POST', + }) + ).json(), + ); + const second = singleTickResponseSchema.parse( + await ( + await app.request('/api/simulation/tick?mutationId=tick_replay_B', { + method: 'POST', + }) + ).json(), + ); + const replay = singleTickResponseSchema.parse( + await ( + await app.request('/api/simulation/tick?mutationId=tick_replay_A', { + method: 'POST', + }) + ).json(), + ); + expect(first).toMatchObject({ tickNumber: 1, snapshot: { tickNumber: 1 } }); + expect(second).toMatchObject({ + tickNumber: 2, + snapshot: { tickNumber: 2 }, + }); + expect(replay).toEqual(first); + expect(calls).toBe(16); + expect( + simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ).tickNumber, + ).toBe(2); + }); + + it('returns one complete simultaneous tick group', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider( + Array.from({ length: 8 }, () => ({ + worldAction: { type: 'wait' as const }, + summary: 'Wait.', + })), + ), + }); + const response = await app.request('/api/simulation/tick', { + method: 'POST', + }); + expect(response.status).toBe(200); + expect(singleTickResponseSchema.parse(await response.json())).toMatchObject( + { + tickNumber: 1, + snapshot: { tickNumber: 1, turnNumber: 8 }, + }, + ); + }); + it('coalesces repeated delivery of the same turn mutation ID', async () => { let calls = 0; const app = createApp({ @@ -121,7 +234,7 @@ describe('game API simulation boundary', () => { expect(payload.experiment.currentTerritory).toHaveLength(8); }); - it('returns a non-turn-consuming cancellation response', async () => { + it('returns a non-tick-consuming cancellation response from the tick route', async () => { let requestStarted!: () => void; const started = new Promise((resolve) => { requestStarted = resolve; @@ -145,10 +258,10 @@ describe('game API simulation boundary', () => { }, }; const app = createApp({ provider }); - const pendingTurn = app.request('/api/simulation/turn', { method: 'POST' }); + const pendingTurn = app.request('/api/simulation/tick', { method: 'POST' }); await started; expect( - (await app.request('/api/simulation/turn/cancel', { method: 'POST' })) + (await app.request('/api/simulation/tick/cancel', { method: 'POST' })) .status, ).toBe(200); const response = await pendingTurn; @@ -160,6 +273,7 @@ describe('game API simulation boundary', () => { snapshot: { status: 'paused', turnNumber: 0, + tickNumber: 0, turns: [], experiment: { totalCompletedTurns: 0 }, }, diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index 22437c2..fccfb79 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -25,6 +25,7 @@ import { restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, + singleTickResponseSchema, updateAgentPersonalityRequestSchema, updateAgentPersonalityResponseSchema, updateExperimentModelsRequestSchema, @@ -109,7 +110,12 @@ export function createApp(options: AppOptions = {}) { const mutationPromise = ( context: Context, operation: - 'turn' | 'retry' | 'unattended-retry' | 'unattended-skip' | 'setup', + | 'turn' + | 'tick' + | 'retry' + | 'unattended-retry' + | 'unattended-skip' + | 'setup', execute: () => Promise, ): Promise => { const supplied = @@ -499,6 +505,43 @@ export function createApp(options: AppOptions = {}) { } }); + app.post('/api/simulation/tick', async (context) => { + try { + const response = await mutationPromise(context, 'tick', async () => { + const records = await service.executeNextTick(); + return singleTickResponseSchema.parse({ + snapshot: service.getSnapshot(), + tickNumber: records[0]!.tickNumber, + records, + }); + }); + return context.json(response); + } catch (error) { + if (error instanceof SimulationTurnCancelledError) + return context.json( + cancelledTurnResponseSchema.parse({ + snapshot: service.getSnapshot(), + cancelled: true, + }), + ); + if (error instanceof SimulationConflictError) + return context.json( + apiErrorSchema.parse({ + error: { code: 'tick_conflict', message: error.message }, + }), + 409, + ); + if (error instanceof SimulationValidationError) + return context.json( + apiErrorSchema.parse({ + error: { code: error.code, message: error.message }, + }), + 409, + ); + throw error; + } + }); + app.post('/api/simulation/turn/cancel', (context) => { try { return context.json( @@ -517,6 +560,24 @@ export function createApp(options: AppOptions = {}) { throw error; } }); + app.post('/api/simulation/tick/cancel', (context) => { + try { + return context.json( + cancelSimulationResponseSchema.parse({ + snapshot: service.cancelCurrentRequest(), + }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) + return context.json( + apiErrorSchema.parse({ + error: { code: 'tick_cancel_conflict', message: error.message }, + }), + 409, + ); + throw error; + } + }); const respondToManualTurn = async ( context: Context, diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index 0fd8cdb..96a3628 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -1,4 +1,5 @@ import { + AGENT_DECISION_CONTRACT_VERSION, experimentExportDocumentSchema, experimentExportPreviewSchema, experimentExportRequestSchema, @@ -29,9 +30,11 @@ import { type WorldSnapshot, type BehaviorConfiguration, type AppliedScenario, + type ExperimentTickSummary, } from '@hexzero/shared'; export interface ExperimentSource { + schemaVersion: 9 | 10; id: ExperimentId; startedAt: string; providerMode: 'openrouter' | 'scripted-test'; @@ -79,6 +82,7 @@ export class ExperimentMetricAccumulator { } if ( turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && turn.communicationResult.accepted && @@ -91,6 +95,7 @@ export class ExperimentMetricAccumulator { } if ( turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && turn.communicationResult.accepted && @@ -170,6 +175,7 @@ interface MutableMetrics { accepted: number; rejected: number; providerErrors: number; + lostTicks: number; operatorSkipped: number; modelCalls: number; failedModelAttempts: number; @@ -273,6 +279,7 @@ function mutableMetrics(): MutableMetrics { accepted: 0, rejected: 0, providerErrors: 0, + lostTicks: 0, operatorSkipped: 0, modelCalls: 0, failedModelAttempts: 0, @@ -366,6 +373,7 @@ function addToMutable( ): void { metrics.turns += 1; if (turn.outcome === 'provider-error') metrics.providerErrors += 1; + else if (turn.outcome === 'lost-tick') metrics.lostTicks += 1; else if (turn.outcome === 'operator-skipped') { metrics.operatorSkipped += 1; if (turn.skipKind === 'unattended') { @@ -376,6 +384,7 @@ function addToMutable( metrics.visited.add(turn.observation.currentCell.cell); if ( turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' ) { if (turn.worldAction.type === 'move') metrics.requestedMoves += 1; @@ -530,12 +539,14 @@ function addToMutable( if ( retried && turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' ) metrics.recoveredByRetry += 1; if ( retried && turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' ) { if (manuallyRetried) metrics.recoveredManually += 1; @@ -581,7 +592,8 @@ function usageAttempts(turn: AgentTurnRecord) { ? [ { provider: turn.provider, - failed: turn.outcome === 'provider-error', + failed: + turn.outcome === 'provider-error' || turn.outcome === 'lost-tick', kind: 'initial' as const, }, ] @@ -599,6 +611,7 @@ function finalizeMutable(metrics: MutableMetrics) { accepted: metrics.accepted, rejected: metrics.rejected, providerErrors: metrics.providerErrors, + lostTicks: metrics.lostTicks, operatorSkipped: metrics.operatorSkipped, modelCalls: metrics.modelCalls, failedModelAttempts: metrics.failedModelAttempts, @@ -731,12 +744,13 @@ export function createExperimentExport( }, ); const document: ExperimentExportDocument = { - schemaVersion: 9, + schemaVersion: source.schemaVersion, generatedAt, experiment: { id: source.id, startedAt: source.startedAt, providerMode: source.providerMode, + decisionContractVersion: AGENT_DECISION_CONTRACT_VERSION, modelConfiguration: structuredClone(source.modelConfiguration), behaviorConfiguration: structuredClone(source.behaviorConfiguration), scenario: structuredClone(source.scenario), @@ -749,6 +763,13 @@ export function createExperimentExport( selection: { selectedAgentIds, matchingTurnCount: filtered.length, + ...(source.schemaVersion === 10 + ? { + matchingTickCount: new Set( + filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), + ).size, + } + : {}), matchingCommunicationCount: communications.length, matchingControlChangeCount: controlChanges.length, matchingDiplomacyEventCount: allianceEvents.length, @@ -809,6 +830,9 @@ export function createExperimentExport( : {}), allianceEvents: structuredClone(allianceEvents), turns: filtered.map((turn) => exportTurn(turn, request)), + ...(source.schemaVersion === 10 + ? { tickSummaries: summarizeTicks(filtered) } + : {}), }; return experimentExportDocumentSchema.parse(document); } @@ -906,6 +930,9 @@ export function createExperimentPreview( return experimentExportPreviewSchema.parse({ experimentId: source.id, matchingTurnCount: document.selection.matchingTurnCount, + ...(document.selection.matchingTickCount === undefined + ? {} + : { matchingTickCount: document.selection.matchingTickCount }), matchingCommunicationCount: document.selection.matchingCommunicationCount, matchingControlChangeCount: document.selection.matchingControlChangeCount, matchingDiplomacyEventCount: document.selection.matchingDiplomacyEventCount, @@ -950,6 +977,7 @@ function filterTurns( selected.has(turn.agentId) && request.outcomes.includes(turn.outcome) && (turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' || request.actions.includes(turn.worldAction.type)), ); @@ -973,6 +1001,7 @@ function filterCommunications( let communications = source.turns.flatMap((turn) => { if ( turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' || !turn.communicationResult.requested ) @@ -1208,6 +1237,16 @@ function exportTurn( request.level !== 'custom' || Boolean(custom?.providerUsageMetadata); const base: ExperimentExportDocument['turns'][number] = { turnNumber: turn.turnNumber, + ...(turn.tickNumber === undefined ? {} : { tickNumber: turn.tickNumber }), + ...(turn.tickPosition === undefined + ? {} + : { tickPosition: turn.tickPosition }), + ...(turn.virtualTime === undefined + ? {} + : { virtualTime: turn.virtualTime }), + ...(turn.tickIntervalMinutes === undefined + ? {} + : { tickIntervalMinutes: turn.tickIntervalMinutes }), startedAt: turn.startedAt, completedAt: turn.completedAt, agentId: turn.agentId, @@ -1217,7 +1256,9 @@ function exportTurn( ...structuredClone(attempt), ...(includeProvider ? {} : { provider: undefined }), })), - ...(turn.outcome === 'provider-error' || turn.outcome === 'operator-skipped' + ...(turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || + turn.outcome === 'operator-skipped' ? { failure: structuredClone(turn.failure) } : { worldAction: structuredClone(turn.worldAction), @@ -1273,6 +1314,7 @@ function exportTurn( } if ( turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && (includeValidation || includeEvent) ) { @@ -1287,6 +1329,48 @@ function exportTurn( return base; } +function summarizeTicks( + records: readonly AgentTurnRecord[], +): ExperimentTickSummary[] { + const groups = new Map(); + for (const record of records) { + if (record.tickNumber === undefined) continue; + groups.set(record.tickNumber, [ + ...(groups.get(record.tickNumber) ?? []), + record, + ]); + } + return [...groups.entries()].map(([tickNumber, tickRecords]) => { + const attempts = tickRecords.flatMap(({ modelAttempts }) => modelAttempts); + const latencies = attempts.map( + ({ provider, failure }) => provider?.latencyMs ?? failure?.latencyMs ?? 0, + ); + const knownCosts = attempts.flatMap(({ provider }) => + provider?.costCredits === undefined ? [] : [provider.costCredits], + ); + return { + tickNumber, + virtualTime: tickRecords[0]!.virtualTime!, + intervalMinutes: tickRecords[0]!.tickIntervalMinutes!, + agentRecordCount: tickRecords.length, + lostTicks: tickRecords.filter(({ outcome }) => outcome === 'lost-tick') + .length, + deadlineMisses: tickRecords.filter( + (record) => + record.outcome === 'lost-tick' && record.failure.code === 'timeout', + ).length, + providerCallCount: attempts.length, + aggregateDecisionLatencyMs: latencies.reduce( + (total, latency) => total + latency, + 0, + ), + maximumDecisionLatencyMs: Math.max(0, ...latencies), + knownCostCredits: knownCosts.reduce((total, cost) => total + cost, 0), + attemptsWithUnknownCost: attempts.length - knownCosts.length, + }; + }); +} + function summarizeEvent( event: Extract< AgentTurnRecord, @@ -1372,6 +1456,7 @@ export function calculateExperimentMetrics( const mostRecentZeroTurn = records.findLast( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && turn.communicationResult.accepted && @@ -1380,6 +1465,7 @@ export function calculateExperimentMetrics( const mostRecentZeroDirective = mostRecentZeroTurn && mostRecentZeroTurn.outcome !== 'provider-error' && + mostRecentZeroTurn.outcome !== 'lost-tick' && mostRecentZeroTurn.outcome !== 'operator-skipped' && mostRecentZeroTurn.communicationResult.requested && mostRecentZeroTurn.communicationResult.accepted && @@ -1407,6 +1493,8 @@ export function calculateExperimentMetrics( providerErrors: records.filter( ({ outcome }) => outcome === 'provider-error', ).length, + lostTicks: records.filter(({ outcome }) => outcome === 'lost-tick') + .length, operatorSkipped: records.filter( ({ outcome }) => outcome === 'operator-skipped', ).length, @@ -1420,39 +1508,47 @@ export function calculateExperimentMetrics( (turn) => !usageAttempts(turn).some(({ kind }) => kind === 'manual-retry') && turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped', ).length, recoveredManually: retriedTurns.filter( (turn) => usageAttempts(turn).some(({ kind }) => kind === 'manual-retry') && turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped', ).length, recoveredByRetry: retriedTurns.filter( ({ outcome }) => - outcome !== 'provider-error' && outcome !== 'operator-skipped', + outcome !== 'provider-error' && + outcome !== 'lost-tick' && + outcome !== 'operator-skipped', ).length, requestedMoves: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'move', ).length, requestedInfections: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'infect', ).length, requestedCaptures: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'capture', ).length, requestedWaits: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'wait', ).length, @@ -1615,10 +1711,14 @@ function diplomacyMetrics( const completed = records.filter( ( turn, - ): turn is Exclude & { + ): turn is Exclude< + AgentTurnRecord, + { outcome: 'provider-error' | 'lost-tick' | 'operator-skipped' } + > & { diplomacyResult: Exclude; } => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.diplomacyResult.requested, ); @@ -1728,6 +1828,7 @@ function diplomacyMetrics( alliedCaptureAttempts: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'capture' && !turn.worldActionResult.accepted && @@ -1736,6 +1837,7 @@ function diplomacyMetrics( alliedCaptureRejections: records.filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.worldAction.type === 'capture' && !turn.worldActionResult.accepted && @@ -1820,6 +1922,7 @@ function groupedDiplomacyRejections(records: readonly AgentTurnRecord[]) { for (const turn of records) { if ( turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' || !turn.diplomacyResult.requested || turn.diplomacyResult.accepted diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 18ee05d..fffb797 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -7,6 +7,7 @@ import { type ProviderDecision, } from '@hexzero/agent-runtime'; import { + AGENT_DECISION_CONTRACT_VERSION, PERSONALITY_MAX_LENGTH, agentIdSchema, agentTurnRecordSchema, @@ -82,6 +83,405 @@ function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') { } describe('SimulationService', () => { + it('freezes all observations, dispatches concurrently, and commits one complete tick', async () => { + const observations: AgentObservation[] = []; + const deadlines = new Set(); + let active = 0; + let maximumActive = 0; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(observation, _model, options): Promise { + observations.push(structuredClone(observation)); + deadlines.add(options?.deadlineAtMs); + active += 1; + maximumActive = Math.max(maximumActive, active); + await Promise.resolve(); + active -= 1; + return { + decision: { + worldAction: { type: 'wait' }, + communication: { + channel: 'public', + message: `hello-${observation.agentName}`, + }, + summary: 'Wait and report.', + }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }); + + const records = await simulation.executeNextTick(); + expect(records).toHaveLength(8); + expect(maximumActive).toBeGreaterThan(1); + expect(deadlines.size).toBe(1); + expect(new Set(records.map(({ tickNumber }) => tickNumber))).toEqual( + new Set([1]), + ); + expect( + observations.every( + ({ recentPublicMessages }) => recentPublicMessages.length === 0, + ), + ).toBe(true); + expect(simulation.getSnapshot()).toMatchObject({ + tickNumber: 1, + turnNumber: 8, + }); + }); + + it('keeps resolution records and world events independent of provider completion order', async () => { + const makeProvider = (reverse: boolean): AgentProvider => ({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(observation): Promise { + const index = DEVELOPMENT_AGENT_BLUEPRINTS.findIndex( + ({ id }) => id === observation.agentId, + ); + for (let count = 0; count < (reverse ? 7 - index : index); count += 1) + await Promise.resolve(); + return { + decision: { + worldAction: { type: 'wait' }, + communication: { + channel: 'public', + message: observation.agentName, + }, + summary: 'Wait.', + }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }); + const forward = service(makeProvider(false)); + const reverse = service(makeProvider(true)); + const forwardRecords = await forward.executeNextTick(); + const reverseRecords = await reverse.executeNextTick(); + expect( + reverseRecords.map(({ agentId, outcome }) => ({ agentId, outcome })), + ).toEqual( + forwardRecords.map(({ agentId, outcome }) => ({ agentId, outcome })), + ); + expect(reverse.getSnapshot().world).toEqual(forward.getSnapshot().world); + }); + + it('resolves same-tick diplomacy contention in deterministic phase order', async () => { + const recipientId = DEVELOPMENT_AGENT_BLUEPRINTS[0]!.id; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(observation): Promise { + return { + decision: { + worldAction: { type: 'wait' }, + ...(observation.agentId === recipientId + ? {} + : { + diplomacy: { type: 'propose-alliance' as const, recipientId }, + }), + summary: 'Propose.', + }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }); + const records = await simulation.executeNextTick(); + type CompletedRecord = Exclude< + AgentTurnRecord, + { outcome: 'provider-error' | 'lost-tick' | 'operator-skipped' } + >; + const completed = records.filter( + (record): record is CompletedRecord => + record.outcome !== 'lost-tick' && + record.outcome !== 'provider-error' && + record.outcome !== 'operator-skipped', + ); + const requested = completed.filter( + (record) => record.diplomacyResult.requested, + ); + expect( + requested.some( + (record) => + record.diplomacyResult.requested && record.diplomacyResult.accepted, + ), + ).toBe(true); + expect( + requested.some( + (record) => + record.diplomacyResult.requested && !record.diplomacyResult.accepted, + ), + ).toBe(true); + expect( + simulation.getSnapshot().world.pendingAllianceProposals, + ).toHaveLength(1); + }); + + it('records one provider failure as a lost tick while committing sibling decisions', async () => { + let failingAgent: string | undefined; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(observation): Promise { + failingAgent ??= observation.agentId; + if (observation.agentId === failingAgent) + throw new AgentProviderError({ + code: 'timeout', + message: 'deadline', + retryable: false, + latencyMs: 37, + }); + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }); + const records = await simulation.executeNextTick(); + expect( + records.filter(({ outcome }) => outcome === 'lost-tick'), + ).toHaveLength(1); + expect( + records.filter(({ outcome }) => outcome === 'accepted'), + ).toHaveLength(7); + expect(simulation.getSnapshot().tickNumber).toBe(1); + const exported = simulation.generateExperimentExport({ + ...exportRequest('minimal'), + outcomes: [ + 'accepted', + 'rejected', + 'lost-tick', + 'provider-error', + 'operator-skipped', + ], + }); + expect(exported.tickSummaries).toEqual([ + expect.objectContaining({ + providerCallCount: 8, + aggregateDecisionLatencyMs: 37, + maximumDecisionLatencyMs: 37, + lostTicks: 1, + }), + ]); + }); + + it('cancels a simultaneous tick atomically without advancing virtual time', async () => { + let started!: () => void; + const requestStarted = new Promise((resolve) => { + started = resolve; + }); + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(_observation, _model, options): Promise { + started(); + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + throw new AgentProviderError({ + code: 'cancelled', + message: 'cancelled', + retryable: false, + }); + }, + }); + const before = simulation.getSnapshot(); + const pending = simulation.executeNextTick(); + await requestStarted; + simulation.cancelCurrentRequest(); + await expect(pending).rejects.toBeInstanceOf(SimulationTurnCancelledError); + expect(simulation.getSnapshot()).toMatchObject({ + tickNumber: before.tickNumber, + turnNumber: before.turnNumber, + virtualTime: before.virtualTime, + turns: before.turns, + world: before.world, + }); + }); + + it('dispatches per-agent model and reasoning overrides for a tick', async () => { + const dispatched: Array<{ + agentId: string; + model: string; + reasoning: string | undefined; + }> = []; + const simulation = service({ + mode: 'scripted-test', + configured: true, + async decide(observation, model, options): Promise { + dispatched.push({ + agentId: observation.agentId, + model, + reasoning: options?.reasoningProfile, + }); + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model, + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }); + simulation.setCompatibleModels(compatibleModels); + const overriddenAgent = simulation.getSnapshot().world.agents[0]!.id; + simulation.updateModelConfiguration({ + globalModelId: compatibleModels[0]!.id, + globalReasoningProfile: 'low', + overrides: [ + { + agentId: overriddenAgent, + modelId: compatibleModels[1]!.id, + reasoningProfile: 'high', + }, + ], + }); + await simulation.executeNextTick(); + expect( + dispatched.find(({ agentId }) => agentId === overriddenAgent), + ).toMatchObject({ + model: compatibleModels[1]!.id, + reasoning: 'high', + }); + expect( + dispatched + .filter(({ agentId }) => agentId !== overriddenAgent) + .every( + ({ model, reasoning }) => + model === compatibleModels[0]!.id && reasoning === 'low', + ), + ).toBe(true); + }); + + it('prevents mixing legacy sequential records and simultaneous ticks in either direction', async () => { + const legacy = service( + new ScriptedAgentProvider([ + { worldAction: { type: 'wait' }, summary: 'Legacy wait.' }, + ]), + ); + await legacy.executeNextTurn(); + await expect(legacy.executeNextTick()).rejects.toBeInstanceOf( + SimulationConflictError, + ); + expect( + legacy.generateExperimentExport(exportRequest('minimal')).schemaVersion, + ).toBe(9); + + const tick = service( + new ScriptedAgentProvider( + Array.from({ length: 8 }, () => ({ + worldAction: { type: 'wait' as const }, + summary: 'Tick wait.', + })), + ), + ); + await tick.executeNextTick(); + await expect(tick.executeNextTurn()).rejects.toBeInstanceOf( + SimulationConflictError, + ); + await expect(tick.retryFailedTurn()).rejects.toBeInstanceOf( + SimulationConflictError, + ); + expect(() => tick.skipFailedTurn()).toThrow(SimulationConflictError); + expect( + tick.generateExperimentExport(exportRequest('minimal')).schemaVersion, + ).toBe(10); + expect( + tick.generateExperimentExport(exportRequest('minimal')).experiment + .decisionContractVersion, + ).toBe(AGENT_DECISION_CONTRACT_VERSION); + }); + + it('reproduces tick order and interval after reset and retains only complete tick groups', async () => { + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(): Promise { + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + costCredits: 0, + }, + }; + }, + }; + const simulation = new SimulationService({ + provider, + now, + createEventId, + experimentRetentionLimit: 10, + }); + await simulation.executeNextTick(); + const first = simulation.getSnapshot(); + await simulation.executeNextTick(); + const retained = simulation.generateExperimentExport({ + ...exportRequest('minimal'), + outcomes: [ + 'accepted', + 'rejected', + 'lost-tick', + 'provider-error', + 'operator-skipped', + ], + }); + expect(retained.turns).toHaveLength(8); + expect(new Set(retained.turns.map(({ tickNumber }) => tickNumber))).toEqual( + new Set([2]), + ); + expect(experimentExportDocumentSchema.safeParse(retained).success).toBe( + true, + ); + expect( + experimentExportDocumentSchema.safeParse({ + ...retained, + tickSummaries: retained.tickSummaries?.map((summary) => ({ + ...summary, + intervalMinutes: summary.intervalMinutes + 1, + })), + }).success, + ).toBe(false); + + simulation.reset(); + await simulation.executeNextTick(); + expect(simulation.getSnapshot()).toMatchObject({ + resolutionOrder: first.resolutionOrder, + lastTickIntervalMinutes: first.lastTickIntervalMinutes, + virtualTime: first.virtualTime, + }); + }); + it('preserves the default and custom physical communication range', () => { const simulation = service( new ScriptedAgentProvider([ @@ -144,8 +544,10 @@ describe('SimulationService', () => { ).not.toMatch(/provider|credential|pendingDecision|gps|future/i); if ( directive.outcome === 'provider-error' || + directive.outcome === 'lost-tick' || directive.outcome === 'operator-skipped' || reply.outcome === 'provider-error' || + reply.outcome === 'lost-tick' || reply.outcome === 'operator-skipped' ) throw new Error('Expected completed Patient Zero communication turns.'); @@ -219,6 +621,7 @@ describe('SimulationService', () => { expect(turn.outcome).toBe('accepted'); if ( turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' ) throw new Error('Expected a completed engine decision.'); @@ -1605,6 +2008,7 @@ describe('SimulationService', () => { }); if ( turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' || !turn.communicationResult.requested || turn.communicationResult.accepted @@ -1653,7 +2057,9 @@ describe('SimulationService', () => { ]; expect( turns.map((turn) => - turn.outcome === 'provider-error' || turn.outcome === 'operator-skipped' + turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || + turn.outcome === 'operator-skipped' ? undefined : turn.communicationResult, ), diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 7877a74..f14d11d 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1,6 +1,7 @@ import { gridDisk, gridDistance } from 'h3-js'; import { AgentProviderError, + dispatchTickDecisions, type AgentProvider, type ProviderDecision, } from '@hexzero/agent-runtime'; @@ -73,6 +74,8 @@ import { getEffectiveAgentColor, physicalDistanceKm, expireAllianceProposals, + seededTickIntervalMinutes, + seededTickOrder, toWorldState, type WorldState, } from '@hexzero/world-engine'; @@ -150,6 +153,10 @@ export class SimulationService { #state: WorldState; #turns: AgentTurnRecord[] = []; #completedTurnCount = 0; + #completedTickCount = 0; + #virtualTime = RESET_GENERATED_AT; + #lastTickIntervalMinutes: number | null = null; + #resolutionOrder: AgentId[] = []; #cursor = 0; #busy = false; #verificationBusy = false; @@ -245,6 +252,10 @@ export class SimulationService { world: this.#worldSnapshot(), scenario: this.#scenario, turnNumber: this.#completedTurnCount, + tickNumber: this.#completedTickCount, + virtualTime: this.#virtualTime, + lastTickIntervalMinutes: this.#lastTickIntervalMinutes, + resolutionOrder: this.#resolutionOrder, nextAgentId: next.id, activeAgentId: this.#activeAgentId, cancellationRequested: this.#cancellationRequested, @@ -282,7 +293,7 @@ export class SimulationService { reset(): SimulationSnapshot { if (this.#busy || this.#verificationBusy) { throw new SimulationConflictError( - 'Reset is unavailable while a model turn is in progress.', + 'Reset is unavailable while model execution is in progress.', ); } this.#status = 'resetting'; @@ -291,6 +302,10 @@ export class SimulationService { ); this.#turns = []; this.#completedTurnCount = 0; + this.#completedTickCount = 0; + this.#virtualTime = RESET_GENERATED_AT; + this.#lastTickIntervalMinutes = null; + this.#resolutionOrder = []; this.#cursor = 0; this.#activeAgentId = null; this.#activeRequestController = null; @@ -424,6 +439,10 @@ export class SimulationService { this.#behaviorConfiguration = nextBehavior; this.#turns = []; this.#completedTurnCount = 0; + this.#completedTickCount = 0; + this.#virtualTime = RESET_GENERATED_AT; + this.#lastTickIntervalMinutes = null; + this.#resolutionOrder = []; this.#cursor = 0; this.#activeAgentId = null; this.#activeRequestController = null; @@ -454,7 +473,7 @@ export class SimulationService { updateModelConfiguration(input: unknown): SimulationSnapshot { if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( - 'Model changes are unavailable while a model turn is in progress.', + 'Model changes are unavailable while model execution is in progress.', ); const parsed = updateExperimentModelsRequestSchema.safeParse(input); if (!parsed.success) @@ -580,7 +599,8 @@ export class SimulationService { version !== 6 && version !== 7 && version !== 8 && - version !== 9 + version !== 9 && + version !== 10 ) throw new SimulationValidationError( 'invalid_model_configuration', @@ -619,7 +639,7 @@ export class SimulationService { 'The imported model assignment is invalid.', ); const importedPatientZero = - version === 9 && + (version === 9 || version === 10) && typeof experiment?.scenario === 'object' && experiment.scenario !== null ? (appliedScenarioSchema.safeParse(experiment.scenario).data @@ -647,7 +667,7 @@ export class SimulationService { locked: false, }; if ( - (version === 8 || version === 9) && + (version === 8 || version === 9 || version === 10) && experiment?.behaviorConfiguration !== undefined ) { const importedBehavior = behaviorConfigurationSchema.safeParse( @@ -695,7 +715,7 @@ export class SimulationService { ): Agent { if (this.#busy || this.#verificationBusy) { throw new SimulationConflictError( - 'Personality changes are unavailable while a model turn is in progress.', + 'Personality changes are unavailable while model execution is in progress.', ); } const agentIdResult = agentIdSchema.safeParse(agentIdInput); @@ -749,7 +769,7 @@ export class SimulationService { restoreDefaultPersonalities(): SimulationSnapshot { if (this.#busy || this.#verificationBusy) { throw new SimulationConflictError( - 'Personality changes are unavailable while a model turn is in progress.', + 'Personality changes are unavailable while model execution is in progress.', ); } const defaults = new Map( @@ -792,7 +812,7 @@ export class SimulationService { previewExperimentExport(request: unknown): ExperimentExportPreview { if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( - 'Export is unavailable while a model turn is in progress.', + 'Export is unavailable while model execution is in progress.', ); return experimentExportPreviewSchema.parse( createExperimentPreview(this.#experimentSource(), request, this.#now()), @@ -802,7 +822,7 @@ export class SimulationService { generateExperimentExport(request: unknown): ExperimentExportDocument { if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( - 'Export is unavailable while a model turn is in progress.', + 'Export is unavailable while model execution is in progress.', ); return experimentExportDocumentSchema.parse( createExperimentExport(this.#experimentSource(), request, this.#now()), @@ -855,6 +875,10 @@ export class SimulationService { } async executeNextTurn(): Promise { + if (this.#completedTickCount > 0) + throw new SimulationConflictError( + 'Legacy sequential turns cannot run after a simultaneous tick.', + ); if (this.#pendingFailedTurn) throw new SimulationConflictError( 'The failed turn must be retried or skipped before starting another turn.', @@ -862,9 +886,276 @@ export class SimulationService { return this.#executeTurnAttempt('initial'); } + /** Execute one atomic simultaneous tick for every active agent. */ + async executeNextTick(): Promise { + if (this.#busy || this.#verificationBusy) + throw new SimulationConflictError( + 'A simulation tick is already in progress.', + ); + if ( + this.#pendingFailedTurn || + (this.#completedTurnCount > 0 && this.#completedTickCount === 0) + ) + throw new SimulationConflictError( + 'A simultaneous tick cannot start inside a legacy sequential experiment. Reset first.', + ); + const agents = [...this.#state.agents.values()]; + const unresolved = agents + .map(({ id }) => this.#resolvedModel(id)) + .filter(({ available }) => !available); + if (unresolved.length) + throw new SimulationValidationError( + 'models_unavailable', + 'Every agent requires an available compatible model before the experiment can run.', + ); + + const tickNumber = this.#completedTickCount + 1; + const preTickState = this.#state; + const observations = new Map( + agents.map(({ id }) => [id, structuredClone(this.#buildObservation(id))]), + ); + const order = seededTickOrder( + agents.map(({ id }) => id), + this.#scenario.worldSeed, + tickNumber, + ); + const interval = seededTickIntervalMinutes( + this.#scenario.worldSeed, + tickNumber, + this.#scenario.minimumTickIntervalMinutes, + this.#scenario.maximumTickIntervalMinutes, + ); + const virtualTime = new Date( + new Date(this.#virtualTime).getTime() + interval * 60_000, + ).toISOString(); + const controller = new AbortController(); + this.#busy = true; + this.#activeRequestController = controller; + this.#activeAgentId = null; + this.#cancellationRequested = false; + this.#status = 'waiting-for-model'; + const deadlineAtMs = Date.now() + OPENROUTER_PROVIDER_TIMEOUT_MS; + try { + const dispatched = await dispatchTickDecisions( + this.#provider, + agents.map(({ id }) => { + const resolved = this.#resolvedModel(id); + return { + agentId: id, + observation: observations.get(id)!, + modelId: resolved.modelId!, + reasoningProfile: resolved.reasoningProfile, + }; + }), + { + concurrency: Math.min(8, agents.length), + deadlineAtMs, + signal: controller.signal, + now: this.#now, + }, + ); + if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + const byAgent = new Map( + dispatched.map((result) => [result.agentId, result]), + ); + const context = { + now: () => virtualTime, + createEventId: this.#createEventId, + createAllianceId: this.#createAllianceId, + createProposalId: this.#createProposalId, + communicationRangeKm: this.#scenario.communicationRangeKm, + patientZeroAgentId: this.#scenario.patientZeroAgentId, + tickNumber, + }; + const recordOrdinal = new Map( + order.map((agentId, index) => [ + agentId, + this.#completedTurnCount + index + 1, + ]), + ); + let state = preTickState; + const actionResults = new Map< + AgentId, + ReturnType['result'] + >(); + for (const agentId of order) { + const result = byAgent.get(agentId)!; + if (result.outcome === 'lost-tick') continue; + const applied = applyWorldAction( + state, + agentId, + result.decision.decision.worldAction, + context, + ); + state = applied.state; + actionResults.set(agentId, applied.result); + } + const communicationResults = new Map< + AgentId, + ReturnType['result'] + >(); + for (const agentId of order) { + const result = byAgent.get(agentId)!; + if (result.outcome === 'lost-tick') continue; + const applied = applyCommunication( + state, + preTickState, + agentId, + result.decision.decision.communication, + context, + ); + state = applied.state; + communicationResults.set(agentId, applied.result); + } + const diplomacyResults = new Map< + AgentId, + ReturnType['result'] + >(); + const diplomacyEvents = new Map(); + for (const agentId of order) { + const result = byAgent.get(agentId)!; + if (result.outcome === 'lost-tick') continue; + const before = state; + const applied = applyDiplomacy( + state, + agentId, + result.decision.decision.diplomacy, + recordOrdinal.get(agentId)!, + context, + ); + state = applied.state; + diplomacyResults.set(agentId, applied.result); + diplomacyEvents.set(agentId, allianceEventsSince(before, state)); + } + const beforeExpiration = state; + state = expireAllianceProposals( + state, + recordOrdinal.get(order.at(-1)!)!, + context, + ); + const expirationEvents = allianceEventsSince(beforeExpiration, state); + if (expirationEvents.length) { + const finalAgentId = order.at(-1)!; + diplomacyEvents.set(finalAgentId, [ + ...(diplomacyEvents.get(finalAgentId) ?? []), + ...expirationEvents, + ]); + } + state = { + ...state, + events: state.events.slice(-MAX_WORLD_EVENT_HISTORY), + }; + + const records = order.map((agentId, index) => { + const result = byAgent.get(agentId)!; + const base = { + turnNumber: this.#completedTurnCount + index + 1, + tickNumber, + tickPosition: index + 1, + virtualTime, + tickIntervalMinutes: interval, + agentId, + startedAt: result.attempts[0]?.startedAt ?? this.#now(), + completedAt: result.attempts.at(-1)?.completedAt ?? this.#now(), + observation: observations.get(agentId)!, + behavior: this.#behaviorFor(agentId), + modelAttempts: result.attempts, + allianceEvents: diplomacyEvents.get(agentId) ?? [], + }; + if (result.outcome === 'lost-tick') + return agentTurnRecordSchema.parse({ + ...base, + outcome: 'lost-tick', + failure: result.failure, + provider: result.attempts.at(-1)?.provider, + }); + const decision = result.decision.decision; + const actionResult = actionResults.get(agentId)!; + return agentTurnRecordSchema.parse({ + ...base, + outcome: actionResult.accepted ? 'accepted' : 'rejected', + worldAction: decision.worldAction, + communication: communicationIntentSchema.safeParse( + decision.communication, + ).data, + diplomacy: diplomacyIntentSchema.safeParse(decision.diplomacy).data, + summary: decision.summary, + worldActionResult: actionResult, + communicationResult: communicationResults.get(agentId)!, + diplomacyResult: diplomacyResults.get(agentId)!, + provider: result.decision.metadata, + }); + }); + if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + this.#commitCompletedTick( + records, + state, + tickNumber, + virtualTime, + interval, + order, + ); + this.#status = 'paused'; + return records; + } catch (error) { + if ( + controller.signal.aborted || + (error && + typeof error === 'object' && + 'failure' in error && + (error as { failure?: ProviderFailure }).failure?.code === + 'cancelled') + ) { + this.#status = 'paused'; + throw new SimulationTurnCancelledError(); + } + throw error; + } finally { + this.#busy = false; + this.#activeRequestController = null; + this.#activeAgentId = null; + this.#cancellationRequested = false; + if (this.#status === 'waiting-for-model') this.#status = 'paused'; + } + } + + #commitCompletedTick( + records: AgentTurnRecord[], + state: WorldState, + tickNumber: number, + virtualTime: string, + interval: number, + order: AgentId[], + ): void { + this.#state = state; + this.#completedTickCount = tickNumber; + this.#virtualTime = virtualTime; + this.#lastTickIntervalMinutes = interval; + this.#resolutionOrder = [...order]; + this.#completedTurnCount = records.at(-1)!.turnNumber; + this.#turns = retainCompleteTickGroups( + [...this.#turns, ...records], + MAX_TURN_HISTORY, + ); + this.#experimentTurns = retainCompleteTickGroups( + [...this.#experimentTurns, ...structuredClone(records)], + this.#experimentRetentionLimit, + ); + for (const record of records) this.#experimentMetrics.add(record); + this.#behaviorConfiguration = { + ...this.#behaviorConfiguration, + locked: true, + }; + this.#modelConfiguration = { ...this.#modelConfiguration, locked: false }; + } + async retryFailedTurn( kind: 'manual-retry' | 'unattended-retry' = 'manual-retry', ): Promise { + if (this.#completedTickCount > 0) + throw new SimulationConflictError( + 'Legacy retry is unavailable after a simultaneous tick.', + ); if (!this.#pendingFailedTurn) throw new SimulationConflictError( 'There is no failed turn awaiting a manual retry.', @@ -875,6 +1166,10 @@ export class SimulationService { skipFailedTurn( skipKind: 'manual' | 'unattended' = 'manual', ): AgentTurnRecord { + if (this.#completedTickCount > 0) + throw new SimulationConflictError( + 'Legacy skip is unavailable after a simultaneous tick.', + ); if (this.#busy || this.#verificationBusy) throw new SimulationConflictError('A model request is still active.'); const pending = this.#pendingFailedTurn; @@ -907,7 +1202,9 @@ export class SimulationService { attemptKind: 'initial' | 'manual-retry' | 'unattended-retry', ): Promise { if (this.#busy || this.#verificationBusy) { - throw new SimulationConflictError('A model turn is already in progress.'); + throw new SimulationConflictError( + 'Model execution is already in progress.', + ); } const agents = [...this.#state.agents.values()]; const unresolved = agents @@ -1345,6 +1642,8 @@ export class SimulationService { modelConfiguration: this.#modelConfiguration, behaviorConfiguration: this.#behaviorConfiguration, scenario: this.#scenario, + schemaVersion: + this.#completedTickCount > 0 || this.#completedTurnCount === 0 ? 10 : 9, }; } @@ -1998,3 +2297,21 @@ function asProviderError(error: unknown): { }, }; } + +function retainCompleteTickGroups( + records: AgentTurnRecord[], + limit: number, +): AgentTurnRecord[] { + if (records.length <= limit) return records; + const groups = new Map(); + for (const record of records) { + const key = record.tickNumber ?? record.turnNumber; + groups.set(key, [...(groups.get(key) ?? []), record]); + } + const retained: AgentTurnRecord[] = []; + for (const group of [...groups.values()].reverse()) { + if (retained.length > 0 && retained.length + group.length > limit) break; + retained.unshift(...group); + } + return retained; +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index c20d36f..aaacd2d 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -10,6 +10,7 @@ import userEvent from '@testing-library/user-event'; import { gridDisk } from 'h3-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + AGENT_DECISION_CONTRACT_VERSION, experimentExportDocumentSchema, assignBehavior, modelCatalogResponseSchema, @@ -765,6 +766,67 @@ function jsonResponse(value: unknown) { return Promise.resolve(new Response(JSON.stringify(value), { status: 200 })); } +function completeTickResponse( + source: SimulationSnapshot, + tickNumber = Math.max(1, source.tickNumber || 1), +) { + const template = source.turns.at(-1) ?? afterInfection().turns[0]!; + const virtualTime = '2026-08-13T12:05:00.000Z'; + const records = source.world.agents.map((agent, index) => + agentTurnRecordSchema.parse({ + ...template, + turnNumber: (tickNumber - 1) * source.world.agents.length + index + 1, + tickNumber, + tickPosition: index + 1, + virtualTime, + tickIntervalMinutes: 5, + agentId: agent.id, + observation: { + ...template.observation, + agentId: agent.id, + agentName: agent.name, + }, + ...(index === 0 + ? {} + : { + outcome: 'accepted', + worldAction: { type: 'wait' }, + communication: undefined, + diplomacy: undefined, + summary: 'Waited for the next tick.', + worldActionResult: { + accepted: true, + event: { + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + agentId: agent.id, + occurredAt: virtualTime, + type: 'agent-waited', + }, + }, + communicationResult: { requested: false }, + diplomacyResult: { requested: false }, + }), + }), + ); + const snapshot = simulationSnapshotSchema.parse({ + ...source, + tickNumber, + turnNumber: records.at(-1)!.turnNumber, + virtualTime, + lastTickIntervalMinutes: 5, + resolutionOrder: records.map(({ agentId }) => agentId), + turns: records, + experiment: { + ...source.experiment, + totalCompletedTurns: records.length, + retainedTurns: records.length, + firstRetainedTurn: 1, + lastRetainedTurn: records.length, + }, + }); + return { snapshot, tickNumber, records }; +} + const compatibleCatalog = modelCatalogResponseSchema.parse({ models: [ { @@ -957,16 +1019,12 @@ async function openAgentsWorkspace(user: ReturnType) { } describe('WorldLab', () => { - it('migrates valid legacy browser preferences once and rejects malformed legacy data', async () => { + it('migrates supported legacy browser preferences and rejects retired targets', async () => { window.localStorage.setItem('agentborne.world-lab.follow-turn', 'false'); window.localStorage.setItem( 'agentborne.world-lab.activity-dock', 'collapsed', ); - window.localStorage.setItem( - 'agentborne.world-lab.unattended-recovery', - JSON.stringify({ enabled: true, retryLimit: 3 }), - ); window.sessionStorage.setItem('agentborne.world-lab.run-target', '500'); const first = render(); await screen.findByRole('button', { name: 'Start' }); @@ -977,12 +1035,9 @@ describe('WorldLab', () => { expect( window.localStorage.getItem('hexzero.world-lab.activity-dock'), ).toBe('collapsed'); - expect( - window.localStorage.getItem('hexzero.world-lab.unattended-recovery'), - ).toBe(JSON.stringify({ enabled: true, retryLimit: 3 })); expect( window.sessionStorage.getItem('hexzero.world-lab.run-target'), - ).toBe('500'); + ).toBe('25'); }); first.unmount(); @@ -993,21 +1048,12 @@ describe('WorldLab', () => { 'false', ); - window.localStorage.removeItem('hexzero.world-lab.unattended-recovery'); - window.localStorage.setItem( - 'agentborne.world-lab.unattended-recovery', - '{"enabled":"yes","retryLimit":99}', - ); second.unmount(); render(); await screen.findByRole('button', { name: 'Start' }); - expect( - window.localStorage.getItem('hexzero.world-lab.unattended-recovery'), - ).not.toBe('{"enabled":"yes","retryLimit":99}'); }); it('renders all controls, status, H3 readiness, and eight visible markers', async () => { - const user = userEvent.setup(); render(); expect( await screen.findByText( @@ -1025,7 +1071,7 @@ describe('WorldLab', () => { screen.getByRole('heading', { name: 'World Lab' }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Single tick' })).toBeEnabled(); fireEvent.click(screen.getByLabelText('More World Lab actions')); expect(screen.getByRole('button', { name: 'Reset world' })).toBeEnabled(); expect( @@ -1042,14 +1088,14 @@ describe('WorldLab', () => { ).not.toBeInTheDocument(); expect( screen - .getAllByRole('option', { name: /^(25|50|100|200|500|1000)$/ }) + .getAllByRole('option', { name: /^(5|10|25|50|100)$/ }) .map((option) => Number((option as HTMLOptionElement).value)), - ).toEqual([25, 50, 100, 200, 500, 1000]); + ).toEqual([5, 10, 25, 50, 100]); expect( - screen.getByLabelText('Experiment details. Turn 0, paused'), + screen.getByLabelText('Experiment details. Tick 0, paused'), ).toBeInTheDocument(); expect( - screen.getByLabelText('Experiment details. Turn 0, paused'), + screen.getByLabelText('Experiment details. Tick 0, paused'), ).toHaveTextContent('0.0 credits'); expect( await screen.findAllByRole('button', { name: /Select agent/ }), @@ -1080,6 +1126,12 @@ describe('WorldLab', () => { expect( screen.getByRole('dialog', { name: 'World Setup' }), ).toBeInTheDocument(); + expect( + screen.getByLabelText('Minimum virtual minutes per tick'), + ).toHaveValue(5); + expect( + screen.getByLabelText('Maximum virtual minutes per tick'), + ).toHaveValue(10); expect(overflowTrigger.closest('details')).not.toHaveAttribute('open'); }); @@ -1114,29 +1166,21 @@ describe('WorldLab', () => { expect(screen.getByLabelText('Patient Zero')).toHaveValue(patientZero.id); }); - it('exposes opt-in bounded unattended recovery with provider-cost guidance', async () => { + it('removes sequential recovery controls and explains per-tick provider cost', async () => { const user = userEvent.setup(); render(); await screen.findByRole('button', { name: 'Start' }); await user.click(screen.getByLabelText('More World Lab actions')); - const toggle = screen.getByRole('checkbox', { - name: 'Enable for continuous playback', - }); - expect(toggle).not.toBeChecked(); - expect(screen.getByLabelText('Unattended retry limit')).toHaveValue('2'); + expect(screen.queryByText('Unattended recovery')).not.toBeInTheDocument(); expect( - screen.getByText(/Each retry may incur provider cost/), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Retry' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Skip turn' }), + ).not.toBeInTheDocument(); expect( - screen.getByText(/World Lab tab must remain open/), + screen.getByText(/Each tick requests every active agent/), ).toBeInTheDocument(); - await user.click(toggle); - await user.selectOptions( - screen.getByLabelText('Unattended retry limit'), - '3', - ); - expect(toggle).toBeChecked(); - expect(screen.getByLabelText('Unattended retry limit')).toHaveValue('3'); }); it.each([ @@ -1273,20 +1317,9 @@ describe('WorldLab', () => { ); }); - it('runs exactly 194 additional turns from turn 6 and never schedules turn 201', async () => { + it('runs exactly 19 additional ticks from tick 6 and never schedules tick 26', async () => { vi.useFakeTimers(); - const turnTemplate = afterInfection().turns[0]!; - const at6 = simulationSnapshotSchema.parse({ - ...initial, - turnNumber: 6, - nextAgentId: world.agents[6]!.id, - experiment: { - ...initial.experiment, - totalCompletedTurns: 6, - droppedRecords: 6, - complete: false, - }, - }); + const at6 = completeTickResponse(initial, 6).snapshot; let turnRequests = 0; vi.stubGlobal( 'fetch', @@ -1297,24 +1330,9 @@ describe('WorldLab', () => { } const turnNumber = 6 + turnRequests; turnRequests += 1; - if (turnNumber > 200) - return Promise.reject(new Error('turn 201 must not be requested')); - const turn = { ...turnTemplate, turnNumber }; - const next = simulationSnapshotSchema.parse({ - ...at6, - turnNumber, - nextAgentId: world.agents[turnNumber % world.agents.length]!.id, - turns: [turn], - experiment: { - ...at6.experiment, - totalCompletedTurns: turnNumber, - retainedTurns: 1, - firstRetainedTurn: turnNumber, - lastRetainedTurn: turnNumber, - droppedRecords: turnNumber - 1, - }, - }); - return jsonResponse({ snapshot: next, turn }); + if (turnNumber > 25) + return Promise.reject(new Error('tick 26 must not be requested')); + return jsonResponse(completeTickResponse(at6, turnNumber)); }), ); @@ -1324,48 +1342,53 @@ describe('WorldLab', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByText('Turn 6')).toBeInTheDocument(); + expect(screen.getByText('Tick 6')).toBeInTheDocument(); await act(async () => { - screen.getByRole('button', { name: 'Run to 200' }).click(); + screen.getByRole('button', { name: 'Run to tick 25' }).click(); }); - for (let expectedTurn = 7; expectedTurn <= 200; expectedTurn += 1) { + for (let expectedTurn = 7; expectedTurn <= 25; expectedTurn += 1) { await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); } - expect(screen.getByText('Turn 200')).toBeInTheDocument(); + expect(screen.getByText('Tick 25')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); - expect(turnRequests - 1).toBe(194); - expect(fetch).toHaveBeenCalledTimes(195); + expect(turnRequests - 1).toBe(19); + expect(fetch).toHaveBeenCalledTimes(20); await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); - expect(turnRequests - 1).toBe(194); - expect(fetch).toHaveBeenCalledTimes(195); + expect(turnRequests - 1).toBe(19); + expect(fetch).toHaveBeenCalledTimes(20); } finally { vi.useRealTimers(); } }, 15_000); it('keeps run targets absolute and makes current or past targets unavailable', async () => { + window.sessionStorage.setItem('hexzero.world-lab.run-target', '25'); const at50 = simulationSnapshotSchema.parse({ ...initial, - turnNumber: 50, - experiment: { ...initial.experiment, totalCompletedTurns: 50 }, + turnNumber: 400, + tickNumber: 50, + virtualTime: '2026-08-13T16:10:00.000Z', + lastTickIntervalMinutes: 5, + resolutionOrder: initial.world.agents.map(({ id }) => id), + experiment: { ...initial.experiment, totalCompletedTurns: 400 }, }); vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(at50)), ); render(); - const selector = await screen.findByLabelText('Run target'); + const selector = await screen.findByLabelText('Tick target'); expect(screen.getByRole('option', { name: '25' })).toBeDisabled(); expect(screen.getByRole('option', { name: '50' })).toBeDisabled(); expect(screen.getByRole('option', { name: '100' })).toBeEnabled(); - expect(selector).toHaveValue('200'); + expect(selector).toHaveValue('100'); }); it('derives allied marker and existing-territory colors while retaining individual ownership labels', async () => { @@ -1542,11 +1565,8 @@ describe('WorldLab', () => { ).toBeInTheDocument(); }); - it('defaults Follow turn on and selects the scheduled agent with a textual indicator', async () => { - const scheduled = simulationSnapshotSchema.parse({ - ...initial, - nextAgentId: world.agents[2]!.id, - }); + it('defaults Follow latest on and selects the latest resolved record', async () => { + const scheduled = afterInfection(); vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(scheduled)), @@ -1554,18 +1574,18 @@ describe('WorldLab', () => { render(); const roster = await screen.findByLabelText('Agent roster'); expect( - within(roster).getByRole('checkbox', { name: 'Follow turn' }), + within(roster).getByRole('checkbox', { name: 'Follow latest' }), ).toBeChecked(); - expect(within(roster).getByText('Next')).toBeInTheDocument(); + expect(within(roster).getByText('Latest')).toBeInTheDocument(); expect( - screen.getByRole('heading', { name: new RegExp(world.agents[2]!.name) }), + screen.getByRole('heading', { name: new RegExp(world.agents[0]!.name) }), ).toBeInTheDocument(); }); - it('follows an active agent and immediately returns to the scheduled agent when re-enabled', async () => { + it('returns to the latest resolved agent when following is re-enabled', async () => { const active = simulationSnapshotSchema.parse({ ...initial, - activeAgentId: world.agents[3]!.id, + turns: [{ ...afterInfection().turns[0]!, agentId: world.agents[3]!.id }], }); vi.stubGlobal( 'fetch', @@ -1574,7 +1594,7 @@ describe('WorldLab', () => { const user = userEvent.setup(); render(); const roster = await screen.findByLabelText('Agent roster'); - expect(within(roster).getByText('Acting')).toBeInTheDocument(); + expect(within(roster).getByText('Latest')).toBeInTheDocument(); expect( screen.getByRole('heading', { name: new RegExp(world.agents[3]!.name) }), ).toBeInTheDocument(); @@ -1584,14 +1604,14 @@ describe('WorldLab', () => { }), ); expect( - within(roster).getByRole('checkbox', { name: 'Follow turn' }), + within(roster).getByRole('checkbox', { name: 'Follow latest' }), ).not.toBeChecked(); expect( screen.getByRole('heading', { name: new RegExp(world.agents[1]!.name) }), ).toBeInTheDocument(); - expect(within(roster).getByText('Acting')).toBeInTheDocument(); + expect(within(roster).getByText('Latest')).toBeInTheDocument(); await user.click( - within(roster).getByRole('checkbox', { name: 'Follow turn' }), + within(roster).getByRole('checkbox', { name: 'Follow latest' }), ); expect( screen.getByRole('heading', { name: new RegExp(world.agents[3]!.name) }), @@ -1608,7 +1628,7 @@ describe('WorldLab', () => { row.focus(); await user.keyboard('{Enter}'); expect( - within(roster).getByRole('checkbox', { name: 'Follow turn' }), + within(roster).getByRole('checkbox', { name: 'Follow latest' }), ).not.toBeChecked(); expect(window.localStorage.getItem('hexzero.world-lab.follow-turn')).toBe( 'false', @@ -1617,7 +1637,7 @@ describe('WorldLab', () => { await user.click(screen.getByRole('button', { name: 'Export' })); expect( screen.getByRole('dialog', { name: 'Experiment export' }), - ).not.toHaveTextContent('Follow turn'); + ).not.toHaveTextContent('Follow latest'); }); it.each([ @@ -1630,7 +1650,7 @@ describe('WorldLab', () => { ['lost-response reconciliation', 'paused', null, 6], ['request in progress', 'running', 7, 0], ] as const)( - 'selects the authoritative active/next agent after %s', + 'selects the latest resolved agent after %s', async (_label, status, activeIndex, nextIndex) => { const current = simulationSnapshotSchema.parse({ ...initial, @@ -1638,20 +1658,24 @@ describe('WorldLab', () => { activeAgentId: activeIndex === null ? null : world.agents[activeIndex]!.id, nextAgentId: world.agents[nextIndex]!.id, + turns: [ + { + ...afterInfection().turns[0]!, + agentId: world.agents[nextIndex]!.id, + }, + ], }); vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(current)), ); render(); - const expected = world.agents[activeIndex ?? nextIndex]!; + const expected = world.agents[nextIndex]!; expect( await screen.findByRole('heading', { name: new RegExp(expected.name) }), ).toBeInTheDocument(); expect( - within(screen.getByLabelText('Agent roster')).getByText( - activeIndex === null ? 'Next' : 'Acting', - ), + within(screen.getByLabelText('Agent roster')).getByText('Latest'), ).toBeInTheDocument(); }, ); @@ -1664,7 +1688,7 @@ describe('WorldLab', () => { .fn() .mockImplementationOnce(() => jsonResponse(initial)) .mockImplementationOnce(() => - jsonResponse({ snapshot: changed, turn: changed.turns[0] }), + jsonResponse(completeTickResponse(changed)), ), ); const user = userEvent.setup(); @@ -1673,7 +1697,7 @@ describe('WorldLab', () => { await screen.findByRole('button', { name: 'Select agent Ember' }), ); await user.click( - await screen.findByRole('button', { name: 'Single turn' }), + await screen.findByRole('button', { name: 'Single tick' }), ); await user.click(screen.getByRole('tab', { name: 'Event log' })); expect( @@ -1811,7 +1835,7 @@ describe('WorldLab', () => { .fn() .mockImplementationOnce(() => jsonResponse(initial)) .mockImplementationOnce(() => - jsonResponse({ snapshot: changed, turn: changed.turns[0] }), + jsonResponse(completeTickResponse(changed)), ), ); const user = userEvent.setup(); @@ -1820,7 +1844,7 @@ describe('WorldLab', () => { await screen.findByRole('button', { name: 'Select agent Ember' }), ); await user.click( - await screen.findByRole('button', { name: 'Single turn' }), + await screen.findByRole('button', { name: 'Single tick' }), ); await user.click(screen.getByRole('tab', { name: 'Event log' })); expect( @@ -1989,7 +2013,7 @@ describe('WorldLab', () => { screen.getByText('Development world loaded with 8 agents.'), ).toBeInTheDocument(), ); - expect(screen.getByText('Turn 0')).toBeInTheDocument(); + expect(screen.getByText('Tick 0')).toBeInTheDocument(); await waitFor(() => expect(screen.getByTestId('world-map')).toHaveAttribute( 'data-rendered-infected-cell-count', @@ -2039,7 +2063,7 @@ describe('WorldLab', () => { await screen.findByText(/Model calls unavailable/), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Single turn' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Single tick' })).toBeDisabled(); }); it('reserves a compact cancel slot when no request is active', async () => { @@ -2200,7 +2224,7 @@ describe('WorldLab', () => { return jsonResponse({ verification: { modelId: 'example/alpha', - contractVersion: 'text-flat-json-v3', + contractVersion: AGENT_DECISION_CONTRACT_VERSION, status: probeCalls === 1 ? 'failed' : 'verified', testedAt: '2026-08-15T12:00:00.000Z', ...(probeCalls === 1 @@ -2240,7 +2264,7 @@ describe('WorldLab', () => { await screen.findByText('Runtime verified: yes'), ).toBeInTheDocument(); expect(screen.getByText(/may incur a small charge/)).toBeInTheDocument(); - expect(screen.getByText('Turn 0')).toBeInTheDocument(); + expect(screen.getByText('Tick 0')).toBeInTheDocument(); expect(probeProfiles).toEqual(['provider-default', 'provider-default']); }); @@ -2279,87 +2303,48 @@ describe('WorldLab', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); }); - it('stops playback after provider failure and unlocks model selection', async () => { - vi.useFakeTimers(); - try { - const current = openRouterSnapshot('example/alpha'); - const template = afterInfection().turns[0]!; - const failure = { - code: 'timeout' as const, - message: 'The model request timed out.', - retryable: true, - model: 'example/alpha', - }; - const failedTurn = agentTurnRecordSchema.parse({ - turnNumber: 1, - agentId: template.agentId, - startedAt: template.startedAt, - completedAt: template.completedAt, - observation: template.observation, - allianceEvents: [], - outcome: 'provider-error', - failure, - }); - const failed = simulationSnapshotSchema.parse({ - ...current, - status: 'provider-error', - pendingFailedTurn: { - turnNumber: 1, - agentId: failedTurn.agentId, - failure, - attempts: [ - { - attemptNumber: 1, - kind: 'initial', - startedAt: failedTurn.startedAt, - completedAt: failedTurn.completedAt, - modelId: 'example/alpha', - reasoningProfile: 'provider-default', - failure, - }, - ], - }, - }); - let turnRequests = 0; - vi.stubGlobal( - 'fetch', - vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith('/models')) return jsonResponse(compatibleCatalog); - if (url.includes('/turn?mutationId=')) { - turnRequests += 1; - return jsonResponse({ snapshot: failed, turn: failedTurn }); - } - return jsonResponse(current); - }), - ); - render(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - fireEvent.click(screen.getByRole('button', { name: 'Start' })); - await act(async () => void (await vi.advanceTimersByTimeAsync(1_000))); - expect(screen.getByText(/Turn stopped/)).toHaveTextContent( - 'example/alpha', - ); - expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Skip turn' })).toBeEnabled(); - expect( - document.querySelector('.cancel-request-slot button'), - ).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: 'Agents' })); - fireEvent.click(screen.getByText('Model: Alpha')); - expect(screen.getByLabelText('Global model')).toBeEnabled(); - await act(async () => void (await vi.advanceTimersByTimeAsync(10_000))); - expect(turnRequests).toBe(1); - } finally { - vi.useRealTimers(); - } + it('renders a final lost tick without sequential recovery controls', async () => { + const current = openRouterSnapshot('example/alpha'); + const completed = completeTickResponse(current); + const failure = { + code: 'timeout' as const, + message: 'The shared tick deadline elapsed.', + retryable: false, + model: 'example/alpha', + }; + const lost = agentTurnRecordSchema.parse({ + ...completed.records[0], + outcome: 'lost-tick', + failure, + provider: undefined, + }); + const records = [lost, ...completed.records.slice(1)]; + const snapshot = simulationSnapshotSchema.parse({ + ...completed.snapshot, + turns: records, + }); + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => + String(input).includes('/tick?mutationId=') + ? jsonResponse({ snapshot, tickNumber: 1, records }) + : jsonResponse(current), + ), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Single tick' })); + expect( + await screen.findByText(/1 agent lost this tick/), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Retry' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Skip turn' }), + ).not.toBeInTheDocument(); }); - it('reconciles a lost turn response from the authoritative snapshot without resubmitting', async () => { + it('reconciles a lost tick response from the authoritative snapshot without resubmitting', async () => { const initial = simulationSnapshotSchema.parse({ ...afterInfection(), turnNumber: 0, @@ -2372,7 +2357,7 @@ describe('WorldLab', () => { lastRetainedTurn: undefined, }, }); - const completed = afterInfection(); + const completed = completeTickResponse(initial).snapshot; let turnRequests = 0; let snapshotRequests = 0; vi.stubGlobal( @@ -2380,7 +2365,7 @@ describe('WorldLab', () => { vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.endsWith('/models')) return jsonResponse(compatibleCatalog); - if (url.includes('/turn?mutationId=')) { + if (url.includes('/tick?mutationId=')) { turnRequests += 1; return Promise.reject(new TypeError('ECONNRESET')); } @@ -2389,14 +2374,50 @@ describe('WorldLab', () => { }), ); render(); - await screen.findByText('Turn 0'); - fireEvent.click(screen.getByRole('button', { name: 'Single turn' })); - await screen.findByText('Turn 1'); + await screen.findByText('Tick 0'); + fireEvent.click(screen.getByRole('button', { name: 'Single tick' })); + await screen.findByText('Tick 1'); expect(turnRequests).toBe(1); - expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Single tick' })).toBeEnabled(); expect(screen.queryByText('Reconciling request…')).not.toBeInTheDocument(); }); + it('polls and disables conflicting controls for an externally active tick with no active agent', async () => { + vi.useFakeTimers(); + const active = simulationSnapshotSchema.parse({ + ...initial, + status: 'waiting-for-model', + activeAgentId: null, + }); + const completed = completeTickResponse(initial).snapshot; + let requests = 0; + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(requests++ === 0 ? active : completed)), + ); + render(); + await act(async () => { + await Promise.resolve(); + }); + expect( + screen.getByLabelText('Experiment details. Tick 0, waiting for model'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Single tick' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Run to tick 25' }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + fireEvent.click(screen.getByLabelText('More World Lab actions')); + expect(screen.getByRole('button', { name: 'World setup' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reset world' })).toBeDisabled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getByText('Tick 1')).toBeInTheDocument(); + vi.useRealTimers(); + }); + it('collapses and expands the bounded activity dock', async () => { vi.stubGlobal( 'fetch', @@ -2439,7 +2460,7 @@ describe('WorldLab', () => { .mockImplementationOnce(() => jsonResponse(first)) .mockImplementationOnce(() => { scrollHeight = 1_100; - return jsonResponse({ snapshot: next, turn: next.turns[0] }); + return jsonResponse(completeTickResponse(next)); }), ); const user = userEvent.setup(); @@ -2453,7 +2474,7 @@ describe('WorldLab', () => { }); fireEvent.scroll(feed); await act(async () => undefined); - await user.click(screen.getByRole('button', { name: 'Single turn' })); + await user.click(screen.getByRole('button', { name: 'Single tick' })); const jump = await screen.findByRole('button', { name: '1 new message · Return to latest', }); @@ -2628,15 +2649,13 @@ describe('WorldLab', () => { await screen.findByRole('button', { name: 'Reset world' }), ); expect(await screen.findByText('Persistent lab edit.')).toBeInTheDocument(); - expect(screen.getByText('Turn 0')).toBeInTheDocument(); + expect(screen.getByText('Tick 0')).toBeInTheDocument(); }); it('confirms restoring defaults and preserves current world progress', async () => { - const progressed = withPersonality( - afterInfection(), - world.agents[0]!.id, - 'Temporary edit.', - ); + const progressed = completeTickResponse( + withPersonality(afterInfection(), world.agents[0]!.id, 'Temporary edit.'), + ).snapshot; const restored = simulationSnapshotSchema.parse({ ...progressed, world: { @@ -2676,7 +2695,7 @@ describe('WorldLab', () => { name: 'Active personality configuration', }), ).toHaveTextContent(world.agents[0]!.personality); - expect(screen.getByText('Turn 1')).toBeInTheDocument(); + expect(screen.getByText('Tick 1')).toBeInTheDocument(); expect(screen.getByTestId('infected-count')).toHaveTextContent( '1 rendered infected', ); @@ -2684,11 +2703,13 @@ describe('WorldLab', () => { }); it('distinguishes an active edit from the immutable latest observation', async () => { - const changed = withPersonality( - afterInfection(), - world.agents[0]!.id, - 'New active personality.', - ); + const changed = completeTickResponse( + withPersonality( + afterInfection(), + world.agents[0]!.id, + 'New active personality.', + ), + ).snapshot; vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(changed)), @@ -2705,7 +2726,7 @@ describe('WorldLab', () => { screen.getByText(world.agents[0]!.personality, { exact: true }), ).toBeInTheDocument(); expect( - screen.getByText(/Immutable input supplied for turn 1/), + screen.getByText(/Immutable input supplied for tick 1 record 1/), ).toBeInTheDocument(); expect( screen.getByText( @@ -2840,6 +2861,7 @@ describe('WorldLab', () => { expect(screen.getByLabelText('JSON serialization')).toHaveValue('compact'); expect(screen.getByLabelText('Communication channel')).toHaveValue('all'); expect(screen.getByLabelText('Communication result')).toHaveValue('all'); + expect(screen.getByRole('checkbox', { name: 'lost tick' })).toBeChecked(); await user.selectOptions( screen.getByLabelText('Communication channel'), 'direct', @@ -2888,6 +2910,7 @@ describe('WorldLab', () => { await user.click(screen.getByRole('checkbox', { name: 'accepted' })); await user.click(screen.getByRole('checkbox', { name: 'rejected' })); await user.click(screen.getByRole('checkbox', { name: 'provider error' })); + await user.click(screen.getByRole('checkbox', { name: 'lost tick' })); await user.click( screen.getByRole('checkbox', { name: 'operator skipped' }), ); @@ -2983,7 +3006,7 @@ describe('WorldLab', () => { await screen.findByText(/Development world fully infected/), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Single tick' })).toBeEnabled(); fireEvent.click(screen.getByLabelText('More World Lab actions')); expect(screen.getByRole('button', { name: 'Reset world' })).toBeEnabled(); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 908ebe9..b11ec18 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -27,7 +27,7 @@ import { reasoningProfilesForModel, restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, - singleTurnResponseSchema, + singleTickResponseSchema, updateAgentPersonalityRequestSchema, updateAgentPersonalityResponseSchema, updateExperimentModelsResponseSchema, @@ -68,14 +68,11 @@ const apiBase = process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; const followTurnStorageKey = 'hexzero.world-lab.follow-turn'; const runTargetStorageKey = 'hexzero.world-lab.run-target'; -const unattendedRecoveryStorageKey = 'hexzero.world-lab.unattended-recovery'; const activityDockStorageKey = 'hexzero.world-lab.activity-dock'; const legacyFollowTurnStorageKey = 'agentborne.world-lab.follow-turn'; const legacyRunTargetStorageKey = 'agentborne.world-lab.run-target'; -const legacyUnattendedRecoveryStorageKey = - 'agentborne.world-lab.unattended-recovery'; const legacyActivityDockStorageKey = 'agentborne.world-lab.activity-dock'; -export const runTargets = [25, 50, 100, 200, 500, 1000] as const; +export const runTargets = [5, 10, 25, 50, 100] as const; function readStoredPreference( storage: Storage, @@ -91,39 +88,6 @@ function readStoredPreference( return legacy; } -function validUnattendedPreference(value: string): boolean { - try { - const parsed: unknown = JSON.parse(value); - if (!parsed || typeof parsed !== 'object') return false; - const preference = parsed as Record; - return ( - typeof preference.enabled === 'boolean' && - (preference.retryLimit === 1 || - preference.retryLimit === 2 || - preference.retryLimit === 3) - ); - } catch { - return false; - } -} - -function unattendedFailureEligible( - failure: NonNullable['failure'], -): boolean { - if ( - failure.code === 'configuration' || - failure.code === 'cancelled' || - (failure.code === 'provider-http' && - [401, 403].includes(failure.httpStatus ?? 0)) - ) - return false; - return ( - failure.retryable || - Boolean(failure.validationCodes?.length) || - ['malformed-response', 'unsupported-response'].includes(failure.code) - ); -} - export function WorldLab() { const [workspaceView, setWorkspaceView] = useState<'live' | 'agents'>('live'); const [inspectorTab, setInspectorTab] = useState< @@ -159,19 +123,11 @@ export function WorldLab() { const [selectedCell, setSelectedCell] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [running, setRunning] = useState(false); - const [runTarget, setRunTarget] = useState<(typeof runTargets)[number]>(200); + const [runTarget, setRunTarget] = useState<(typeof runTargets)[number]>(25); const [runTargetLoaded, setRunTargetLoaded] = useState(false); const [boundedRunTarget, setBoundedRunTarget] = useState(null); const [inFlight, setInFlight] = useState(false); const [reconciling, setReconciling] = useState(false); - const [unattendedRecoveryEnabled, setUnattendedRecoveryEnabled] = - useState(false); - const [unattendedRetryLimit, setUnattendedRetryLimit] = useState<1 | 2 | 3>( - 2, - ); - const [unattendedPreferenceLoaded, setUnattendedPreferenceLoaded] = - useState(false); - const [unattendedRetryCount, setUnattendedRetryCount] = useState(0); const [recoveryNotice, setRecoveryNotice] = useState(null); const [resetting, setResetting] = useState(false); const [personalityPending, setPersonalityPending] = useState(false); @@ -200,9 +156,6 @@ export function WorldLab() { const completedTurnsRef = useRef(0); const mutationSequenceRef = useRef(0); const runningRef = useRef(false); - const unattendedEnabledRef = useRef(false); - const unattendedRetryLimitRef = useRef<1 | 2 | 3>(2); - const unattendedRetryCountRef = useRef(0); const configurationPendingRef = useRef(false); const exportInitializedRef = useRef(false); const exportTriggerRef = useRef(null); @@ -239,51 +192,6 @@ export function WorldLab() { ); }, [activityDockLoaded, chatCollapsed]); - useEffect(() => { - const saved = readStoredPreference( - window.localStorage, - unattendedRecoveryStorageKey, - legacyUnattendedRecoveryStorageKey, - validUnattendedPreference, - ); - const hydrationTask = window.setTimeout(() => { - if (saved) { - try { - const preference = JSON.parse(saved) as { - enabled?: unknown; - retryLimit?: unknown; - }; - const limit = Number(preference.retryLimit); - setUnattendedRecoveryEnabled(preference.enabled === true); - unattendedEnabledRef.current = preference.enabled === true; - if (limit === 1 || limit === 2 || limit === 3) { - setUnattendedRetryLimit(limit); - unattendedRetryLimitRef.current = limit; - } - } catch { - // Ignore malformed browser-local preferences. - } - } - setUnattendedPreferenceLoaded(true); - }, 0); - return () => window.clearTimeout(hydrationTask); - }, []); - - useEffect(() => { - if (!unattendedPreferenceLoaded) return; - window.localStorage.setItem( - unattendedRecoveryStorageKey, - JSON.stringify({ - enabled: unattendedRecoveryEnabled, - retryLimit: unattendedRetryLimit, - }), - ); - }, [ - unattendedPreferenceLoaded, - unattendedRecoveryEnabled, - unattendedRetryLimit, - ]); - useEffect(() => { if ( !recoveryNotice || @@ -307,7 +215,14 @@ export function WorldLab() { ); const hydrationTask = window.setTimeout(() => { if (runTargets.includes(stored as (typeof runTargets)[number])) { - setRunTarget(stored as (typeof runTargets)[number]); + const storedTarget = stored as (typeof runTargets)[number]; + setRunTarget( + storedTarget > completedTurnsRef.current + ? storedTarget + : (runTargets.find( + (target) => target > completedTurnsRef.current, + ) ?? runTargets.at(-1)!), + ); } setRunTargetLoaded(true); }, 0); @@ -339,26 +254,29 @@ export function WorldLab() { window.localStorage.setItem(followTurnStorageKey, String(followTurn)); }, [followPreferenceLoaded, followTurn]); - const followedAgentId = snapshot?.activeAgentId ?? snapshot?.nextAgentId; + const followedAgentId = + snapshot?.turns.at(-1)?.agentId ?? snapshot?.world.agents[0]?.id; const applySnapshot = useCallback((next: SimulationSnapshot) => { - completedTurnsRef.current = next.experiment.totalCompletedTurns; + completedTurnsRef.current = next.tickNumber; + setRunTarget((current) => + current > next.tickNumber + ? current + : (runTargets.find((target) => target > next.tickNumber) ?? + runTargets.at(-1)!), + ); setSnapshot(next); if ( next.status === 'configuration-error' || next.world.hexes.every(({ state }) => state === 'infected') || (boundedRunTargetRef.current !== null && - next.experiment.totalCompletedTurns >= boundedRunTargetRef.current) + next.tickNumber >= boundedRunTargetRef.current) ) { runningRef.current = false; setRunning(false); setBoundedRunTarget(null); boundedRunTargetRef.current = null; } - if (next.pendingFailedTurn === null) { - unattendedRetryCountRef.current = 0; - setUnattendedRetryCount(0); - } setSelectedAgentId((current) => current ?? next.world.agents[0]!.id); }, []); @@ -372,7 +290,7 @@ export function WorldLab() { await response.json(), ); applySnapshot(authoritative); - if (authoritative.activeAgentId === null) return authoritative; + if (authoritative.status !== 'waiting-for-model') return authoritative; await new Promise((resolve) => window.setTimeout(resolve, 500)); } } finally { @@ -574,153 +492,72 @@ export function WorldLab() { } }; - const executeTurn = useCallback( - async (operation: 'turn' | 'retry' | 'unattended-retry' = 'turn') => { - if (inFlightRef.current) return; - if ( - boundedRunTargetRef.current !== null && - completedTurnsRef.current >= boundedRunTargetRef.current - ) { - setRunning(false); - setBoundedRunTarget(null); - boundedRunTargetRef.current = null; - return; - } - inFlightRef.current = true; - setInFlight(true); - setUiError(null); - try { - mutationSequenceRef.current += 1; - const mutationId = `mutation_${Date.now()}_${mutationSequenceRef.current}`; - const turnPath = `${apiBase}/turn${ - operation === 'retry' - ? '/retry' - : operation === 'unattended-retry' - ? '/unattended-retry' - : '' - }`; - const response = await fetch( - `${turnPath}?mutationId=${encodeURIComponent(mutationId)}`, - { method: 'POST' }, - ); - if (response.status === 409) { - setUiError('Another turn is already in progress.'); - runningRef.current = false; - setRunning(false); - setBoundedRunTarget(null); - boundedRunTargetRef.current = null; - return; - } - if (!response.ok) throw new Error('turn request failed'); - const body: unknown = await response.json(); - const cancellation = cancelledTurnResponseSchema.safeParse(body); - if (cancellation.success) { - applySnapshot(cancellation.data.snapshot); - setUiError('The request was cancelled without consuming a turn.'); - return; - } - const payload = singleTurnResponseSchema.parse(body); - const completedUnattendedRetries = unattendedRetryCountRef.current; - applySnapshot(payload.snapshot); - if (payload.turn.outcome === 'provider-error') { - const failedAgent = payload.snapshot.world.agents.find( - ({ id }) => id === payload.turn.agentId, - ); - const attribution = `${failedAgent?.name ?? payload.turn.agentId} · model ${payload.turn.failure.model ?? payload.turn.provider?.model ?? 'unavailable'} · ${payload.turn.failure.code}: ${payload.turn.failure.message}`; - if ( - runningRef.current && - unattendedEnabledRef.current && - unattendedFailureEligible(payload.turn.failure) - ) { - setRecoveryNotice( - `Recovery pending for ${attribution}. Each retry may incur provider cost.`, - ); - } else { - runningRef.current = false; - setRunning(false); - setBoundedRunTarget(null); - boundedRunTargetRef.current = null; - setUiError(`Turn stopped (${attribution}).`); - } - } else if (operation === 'unattended-retry') { - setRecoveryNotice( - `Recovered the failed turn after ${completedUnattendedRetries} unattended ${completedUnattendedRetries === 1 ? 'retry' : 'retries'}. Continuing; provider calls may incur cost.`, - ); - } - } catch { - setUiError('The response was lost. Reconciling with the Game API…'); - try { - await reconcileAuthoritativeSnapshot(); - setUiError(null); - } catch { - runningRef.current = false; - setRunning(false); - setBoundedRunTarget(null); - boundedRunTargetRef.current = null; - setUiError( - 'The authoritative state could not be reconciled. Refresh before retrying.', - ); - } - } finally { - inFlightRef.current = false; - setInFlight(false); - } - }, - [applySnapshot, reconcileAuthoritativeSnapshot], - ); - - const skipFailedTurn = async () => { + const executeTurn = useCallback(async () => { if (inFlightRef.current) return; - setInFlight(true); - inFlightRef.current = true; - setUiError(null); - try { - const response = await fetch(`${apiBase}/turn/skip`, { method: 'POST' }); - if (!response.ok) throw new Error('skip request failed'); - const payload = singleTurnResponseSchema.parse(await response.json()); - applySnapshot(payload.snapshot); - } catch { - setUiError('The failed turn could not be skipped safely.'); - } finally { - inFlightRef.current = false; - setInFlight(false); + if ( + boundedRunTargetRef.current !== null && + completedTurnsRef.current >= boundedRunTargetRef.current + ) { + setRunning(false); + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; + return; } - }; - - const unattendedSkipFailedTurn = useCallback(async () => { - if (inFlightRef.current || !runningRef.current) return; inFlightRef.current = true; setInFlight(true); + setUiError(null); try { mutationSequenceRef.current += 1; - const mutationId = `unattended_skip_${Date.now()}_${mutationSequenceRef.current}`; + const mutationId = `mutation_${Date.now()}_${mutationSequenceRef.current}`; + const turnPath = `${apiBase}/tick`; const response = await fetch( - `${apiBase}/turn/unattended-skip?mutationId=${encodeURIComponent(mutationId)}`, + `${turnPath}?mutationId=${encodeURIComponent(mutationId)}`, { method: 'POST' }, ); - if (!response.ok) throw new Error('unattended skip failed'); - const payload = singleTurnResponseSchema.parse(await response.json()); - const completedUnattendedRetries = unattendedRetryCountRef.current; - const agent = payload.snapshot.world.agents.find( - ({ id }) => id === payload.turn.agentId, - ); + if (response.status === 409) { + setUiError('Another tick is already in progress.'); + runningRef.current = false; + setRunning(false); + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; + return; + } + if (!response.ok) throw new Error('turn request failed'); + const body: unknown = await response.json(); + const cancellation = cancelledTurnResponseSchema.safeParse(body); + if (cancellation.success) { + applySnapshot(cancellation.data.snapshot); + setUiError('The request was cancelled without consuming a tick.'); + return; + } + const payload = singleTickResponseSchema.parse(body); applySnapshot(payload.snapshot); - setRecoveryNotice( - `${agent?.name ?? payload.turn.agentId} lost the turn after ${completedUnattendedRetries} unattended ${completedUnattendedRetries === 1 ? 'retry' : 'retries'}. Continuing.`, + const lost = payload.records.filter( + ({ outcome }) => outcome === 'lost-tick', ); + if (lost.length) + setRecoveryNotice( + `${lost.length} agent${lost.length === 1 ? '' : 's'} lost this tick; all other decisions committed.`, + ); } catch { - runningRef.current = false; - setRunning(false); - setBoundedRunTarget(null); - boundedRunTargetRef.current = null; - setUiError( - 'The failed turn could not be skipped safely. Playback stopped.', - ); + setUiError('The response was lost. Reconciling with the Game API…'); + try { + await reconcileAuthoritativeSnapshot(); + setUiError(null); + } catch { + runningRef.current = false; + setRunning(false); + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; + setUiError( + 'The authoritative state could not be reconciled. Refresh before retrying.', + ); + } } finally { inFlightRef.current = false; setInFlight(false); } - }, [applySnapshot]); + }, [applySnapshot, reconcileAuthoritativeSnapshot]); const cancelCurrentRequest = async () => { setCancelling(true); @@ -729,7 +566,7 @@ export function WorldLab() { setBoundedRunTarget(null); boundedRunTargetRef.current = null; try { - const response = await fetch(`${apiBase}/turn/cancel`, { + const response = await fetch(`${apiBase}/tick/cancel`, { method: 'POST', }); const body = await response.json(); @@ -755,51 +592,12 @@ export function WorldLab() { completedTurnsRef.current >= boundedRunTargetRef.current ) return; - const pending = snapshot?.pendingFailedTurn; - if (pending) { - if ( - !unattendedRecoveryEnabled || - !unattendedFailureEligible(pending.failure) - ) - return; - if (unattendedRetryCountRef.current < unattendedRetryLimit) { - const nextRetry = unattendedRetryCountRef.current + 1; - const agent = snapshot.world.agents.find( - ({ id }) => id === pending.agentId, - ); - const timer = window.setTimeout(() => { - if (!runningRef.current) return; - unattendedRetryCountRef.current = nextRetry; - setUnattendedRetryCount(nextRetry); - setRecoveryNotice( - `Recovering ${agent?.name ?? pending.agentId} · retry ${nextRetry} of ${unattendedRetryLimit}. Each retry may incur provider cost.`, - ); - void executeTurn('unattended-retry'); - }, speed); - return () => window.clearTimeout(timer); - } - const timer = window.setTimeout( - () => void unattendedSkipFailedTurn(), - speed, - ); - return () => window.clearTimeout(timer); - } const timer = window.setTimeout(() => void executeTurn(), speed); return () => window.clearTimeout(timer); - }, [ - executeTurn, - inFlight, - resetting, - running, - snapshot, - speed, - unattendedRecoveryEnabled, - unattendedRetryLimit, - unattendedSkipFailedTurn, - ]); + }, [executeTurn, inFlight, resetting, running, snapshot, speed]); useEffect(() => { - if (!snapshot?.activeAgentId || inFlight) return; + if (snapshot?.status !== 'waiting-for-model' || inFlight) return; const timer = window.setInterval(() => { void fetch(apiBase) .then(async (response) => { @@ -809,7 +607,7 @@ export function WorldLab() { .catch(() => undefined); }, 500); return () => window.clearInterval(timer); - }, [applySnapshot, inFlight, snapshot?.activeAgentId]); + }, [applySnapshot, inFlight, snapshot?.status]); const reset = async () => { if (inFlightRef.current) return; @@ -817,7 +615,7 @@ export function WorldLab() { snapshot && snapshot.experiment.totalCompletedTurns > 0 && !window.confirm( - `Reset World will discard ${snapshot.experiment.totalCompletedTurns} completed experiment turn records and all unexported telemetry. Continue?`, + `Reset World will discard ${snapshot.tickNumber} completed ticks (${snapshot.experiment.totalCompletedTurns} agent records) and all unexported telemetry. Continue?`, ) ) return; @@ -830,15 +628,14 @@ export function WorldLab() { try { const response = await fetch(`${apiBase}/reset`, { method: 'POST' }); if (response.status === 409) { - setUiError('Reset is unavailable until the current turn completes.'); + setUiError('Reset is unavailable until the current tick completes.'); return; } if (!response.ok) throw new Error('reset request failed'); const payload = resetSimulationResponseSchema.parse( await response.json(), ); - completedTurnsRef.current = - payload.snapshot.experiment.totalCompletedTurns; + completedTurnsRef.current = payload.snapshot.tickNumber; setSnapshot(payload.snapshot); setSelectedCell(null); setSelectedAgentId(payload.snapshot.world.agents[0]!.id); @@ -868,7 +665,7 @@ export function WorldLab() { }); if (response.status === 409) { setUiError( - 'Personality changes are unavailable until the current turn completes.', + 'Personality changes are unavailable until the current tick completes.', ); return false; } @@ -911,7 +708,7 @@ export function WorldLab() { ); if (response.status === 409) { setUiError( - 'Default personalities cannot be restored until the current turn completes.', + 'Default personalities cannot be restored until the current tick completes.', ); return; } @@ -976,35 +773,31 @@ export function WorldLab() { ? 'reconciling-request' : inFlight ? 'waiting-for-model' - : snapshot.status === 'configuration-error' || + : snapshot.status === 'waiting-for-model' || + snapshot.status === 'configuration-error' || snapshot.status === 'provider-error' ? snapshot.status : running ? 'running' : 'paused'; + const activeTick = snapshot.status === 'waiting-for-model'; const personalityControlsDisabled = running || inFlight || resetting || personalityPending || - snapshot.activeAgentId !== null; + snapshot.activeAgentId !== null || + activeTick; const exportMutationPending = running || inFlight || resetting || personalityPending || - snapshot.activeAgentId !== null; + snapshot.activeAgentId !== null || + activeTick; const modelsReady = snapshot.resolvedModels.every( ({ available }) => available, ); - const unattendedCanRecoverPending = Boolean( - snapshot.pendingFailedTurn && - unattendedRecoveryEnabled && - unattendedFailureEligible(snapshot.pendingFailedTurn.failure), - ); - const unattendedRecoveryActive = Boolean( - running && unattendedCanRecoverPending, - ); const reasoningUnavailable = snapshot.resolvedModels.some( ({ issue }) => issue === 'reasoning-unavailable', ); @@ -1050,11 +843,11 @@ export function WorldLab() {
- -

- While continuous playback is active, retry a failed model turn - up to this limit, then skip the turn and continue. Each retry - may incur provider cost. The World Lab tab must remain open. -

-
@@ -3430,8 +3181,9 @@ function AgentRoster({ onFollowTurnChange: (follow: boolean) => void; onSelect: (agentId: AgentId) => void; }) { - const followedAgentId = snapshot.activeAgentId ?? snapshot.nextAgentId; - const followedLabel = snapshot.activeAgentId ? 'Acting' : 'Next'; + const followedAgentId = + snapshot.turns.at(-1)?.agentId ?? snapshot.world.agents[0]!.id; + const followedLabel = 'Latest'; const rowRefs = useRef(new Map()); useEffect(() => { @@ -3451,7 +3203,7 @@ function AgentRoster({ checked={followTurn} onChange={(event) => onFollowTurnChange(event.target.checked)} /> - Follow turn + Follow latest {snapshot.world.agents.map((agent) => { @@ -3549,6 +3301,7 @@ function PrivateComms({ .filter( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && !turn.communicationResult.accepted && @@ -3602,6 +3355,7 @@ function PrivateComms({ const turn = snapshot.turns.find( (candidate) => candidate.outcome !== 'provider-error' && + candidate.outcome !== 'lost-tick' && candidate.outcome !== 'operator-skipped' && candidate.communicationResult.requested && candidate.communicationResult.accepted && @@ -3671,6 +3425,7 @@ function PrivateComms({ {rejections.map((turn) => { if ( turn.outcome === 'provider-error' || + turn.outcome === 'lost-tick' || turn.outcome === 'operator-skipped' || !turn.communicationResult.requested || turn.communicationResult.accepted @@ -3814,6 +3569,7 @@ function PublicWorldChat({ const turnNumber = turns.find( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && turn.communicationResult.accepted && @@ -3943,7 +3699,10 @@ function AlliancePanel({ snapshot }: { snapshot: SimulationSnapshot }) { ({ id }) => id === proposal.recipientAgentId, )?.name } - ; expires after turn {proposal.expirationTurn} + ; expires after{' '} + {proposal.expirationTick === undefined + ? `legacy turn ${proposal.expirationTurn}` + : `tick ${proposal.expirationTick}`} ))} @@ -4140,7 +3899,10 @@ function AgentInspector({ ({ id }) => id === proposal.recipientAgentId, )?.name } - ; expires after turn {proposal.expirationTurn} + ; expires after{' '} + {proposal.expirationTick === undefined + ? `legacy turn ${proposal.expirationTurn}` + : `tick ${proposal.expirationTick}`} ))} @@ -4221,6 +3983,7 @@ function AgentInspector({ const turnNumber = turns.find( (turn) => turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && turn.communicationResult.requested && turn.communicationResult.accepted && @@ -4361,6 +4124,7 @@ function AgentInspector({ {latestTurn.outcome}

{latestTurn.outcome !== 'provider-error' && + latestTurn.outcome !== 'lost-tick' && latestTurn.outcome !== 'operator-skipped' ? ( <>

@@ -4463,8 +4227,9 @@ function AgentInspector({

Latest structured observation

- Immutable input supplied for turn {latestTurn.turnNumber}. It is - not rewritten when the active personality changes. + Immutable input supplied for tick{' '} + {latestTurn.tickNumber ?? 'legacy'} record {latestTurn.turnNumber} + . It is not rewritten when the active personality changes.

{latestTurn.observation.personality !== agent.personality && (

@@ -4625,8 +4390,20 @@ function ExperimentExportPanel({ const [fromTurn, setFromTurn] = useState(1); const [toTurn, setToTurn] = useState(120); const [outcomes, setOutcomes] = useState< - Array<'accepted' | 'rejected' | 'provider-error' | 'operator-skipped'> - >(['accepted', 'rejected', 'provider-error', 'operator-skipped']); + Array< + | 'accepted' + | 'rejected' + | 'lost-tick' + | 'provider-error' + | 'operator-skipped' + > + >([ + 'accepted', + 'rejected', + 'lost-tick', + 'provider-error', + 'operator-skipped', + ]); const [actions, setActions] = useState< Array<'move' | 'infect' | 'capture' | 'wait'> >(['move', 'infect', 'capture', 'wait']); @@ -4956,7 +4733,13 @@ function ExperimentExportPanel({ )} setOutcomes(toggle(outcomes, value))} /> @@ -5273,6 +5056,8 @@ function formatTurn( turn: AgentTurnRecord, agents: SimulationSnapshot['world']['agents'], ) { + if (turn.outcome === 'lost-tick') + return `Lost tick ${turn.tickNumber}: ${turn.failure.code}`; if (turn.outcome === 'provider-error') return `Provider failure · ${turn.failure.message}`; if (turn.outcome === 'operator-skipped') diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fb0fe2..ad8c35c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,25 @@ # Architecture +## Simultaneous tick authority + +The Game API owns an operator-triggered tick transaction. It freezes the world +and builds every observation before dispatching any model request. A +provider-neutral runtime dispatcher starts jobs concurrently with bounded +concurrency, resolved model/reasoning identity, immutable per-agent observation, +and one shared absolute deadline. Provider completion order is never resolution +order. + +The simulation derives a reproducible per-tick agent order from the scenario +seed. The world engine then resolves all world actions, all communications using +the frozen pre-tick eligibility state, all diplomacy, and one proposal-expiration +pass. Only the complete candidate state and complete record group commit. +Cancellation aborts outstanding jobs and commits neither records, events, tick, +nor virtual time. The browser accelerates explicit tick requests; it does not +schedule authoritative work in the background. + ## Persistent World Lab operator shell -World Lab owns one browser execution controller at the root of its client component. The controller centralizes authoritative snapshot reconciliation, mutation IDs, playback timing, bounded-run targets, cancellation, and unattended recovery. Switching between the Live and Agents workspaces changes only the presented workspace; it does not unmount or duplicate the controller, its timer, or its in-flight request state. +World Lab owns one browser execution controller at the root of its client component. The controller centralizes authoritative snapshot reconciliation, mutation IDs, playback timing, bounded tick targets, and cancellation. Switching between the Live and Agents workspaces changes only the presented workspace; it does not unmount or duplicate the controller, its timer, or its in-flight request state. The Live workspace is a grid of independently scrolling agent rail, map, contextual inspector, and bounded activity dock. Agent and hex selections select the corresponding semantic inspector tab, while Scoreboard and Run remain directly reachable. The Agents workspace reuses the same snapshot and existing server mutations for model, reasoning, personality, and strategy assignment. Roster replacement remains a World Setup operation that creates a replacement experiment rather than mutating the active roster mid-run. @@ -16,17 +33,17 @@ World Lab → Game API / simulation service → agent runtime → one decision The Game API also owns one process-local experiment record. Each completed safe turn is captured once, independently from the browser snapshot, and server-side export levels filter that record without affecting provider requests. -Completed schema-v9 exports may cross a separate offline archive boundary into `packages/experiment-archive`. Node's built-in SQLite stores normalized immutable research records through versioned migrations, foreign keys, prepared statements, and transactional idempotent imports. This downstream observability archive is never consulted by turn execution and cannot recover, resume, or mutate the active world. Its bounded query service is application-independent so a future read-only MCP adapter can reuse it without exposing arbitrary SQL. +Completed schema-v10 exports and compatible schema-v9 exports may cross a separate offline archive boundary into `packages/experiment-archive`. Node's built-in SQLite stores normalized immutable research records through versioned migrations, foreign keys, prepared statements, and transactional idempotent imports. This downstream observability archive is never consulted by tick execution and cannot recover, resume, or mutate the active world. Its bounded query service is application-independent so a future read-only MCP adapter can reuse it without exposing arbitrary SQL. World Setup uses `world-scenario-v1`. Pure preview computes the actual H3 disk, exact count, summed cell area, deterministic roster/spawns, feasibility, and warnings. Apply recomputes and atomically replaces world and experiment state. Reset reconstructs the current scenario; the Toledo default preserves legacy starts. Explicit location search crosses a replaceable server-owned adapter with no autocomplete, a one-request-per-second Nominatim limit, bounded cache/timeout, normalized results, and OpenStreetMap attribution. Manual coordinates bypass that network boundary. -World Lab may opt into browser-driven unattended recovery for continuous Start or Run-to sessions. After the existing initial call and at most one internal repair/transport retry, the browser serially requests one-call unattended retries up to a local limit of one through three, then commits one attributed unattended skip. Every mutation has a fresh idempotency key and uses the authoritative pending turn. Pausing, cancellation, ineligible failures, or failed reconciliation stops the loop. The tab must remain open; this is not scheduling, simultaneous ticks, or a production recovery service. +World Lab issues explicit ticks while Start or a bounded run is active. Provider recovery is contained inside each job's shared tick deadline. Lost ticks are final; the browser exposes no pending Retry/Skip or unattended-recovery loop. ## Applications -`apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one turn at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. +`apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one tick at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. -Its command navbar is the single persistent application-control row. Browser-session run-target selection remains client orchestration and preserves absolute turn semantics; execution and reconciliation still consume authoritative API snapshots. Global and per-agent selectors share one deduplicating, case-insensitive model-option builder. Current alliance membership is the first UI color authority for map, roster, chat, and log accents, followed by retained effective color, base agent color, and a neutral fallback. +Its command navbar is the single persistent application-control row. Browser-session run-target selection remains client orchestration and preserves absolute tick semantics; execution and reconciliation still consume authoritative API snapshots. Global and per-agent selectors share one deduplicating, case-insensitive model-option builder. Current alliance membership is the first UI color authority for map, roster, chat, and log accents, followed by retained effective color, base agent color, and a neutral fallback. The default basemap is tokenless CARTO Dark Matter with OpenStreetMap and CARTO attribution. Deterministic tests inspect its configuration and mocked MapLibre H3 sources without requesting external tiles. @@ -35,7 +52,10 @@ Communication resolves against the authoritative pre-action snapshot. Public cha `apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, monotonic completed-turn count, turn cursor, bounded histories, and overlap lock. It exposes: - `GET /api/simulation` — current authoritative snapshot -- `POST /api/simulation/turn` — one agent observation, one provider decision, one engine application +- `POST /api/simulation/tick` — one simultaneous decision opportunity for every active roster agent +- `POST /api/simulation/tick/cancel` — atomically abort the active tick +- `POST /api/simulation/turn`, `/turn/retry`, `/turn/skip`, and unattended turn variants — legacy sequential/schema-v9 compatibility only; they cannot mix with committed ticks +- `POST /api/simulation/turn/cancel` — legacy cancellation alias - `POST /api/simulation/reset` — deterministic reset, rejected while a turn is active - `POST /api/simulation/agents/:agentId/personality` — trim, validate, and replace one active personality - `POST /api/simulation/personalities/restore-defaults` — restore all eight milestone personality directives without resetting progress @@ -45,33 +65,38 @@ Communication resolves against the authoritative pre-action snapshot. Public cha - `POST /api/simulation/models/refresh` — explicitly refresh that catalog - `POST /api/simulation/models/verify` — make one explicit, non-mutating compatibility probe - `POST /api/simulation/experiment/models` — replace the unlocked global/per-agent assignment -- `POST /api/simulation/turn/cancel` — abort the active provider request without mutating the world or consuming a turn - `POST /api/simulation/experiment/import` — restore model assignments from a validated export The legacy `GET /api/development-world` and `GET /health` endpoints remain for low-level diagnostics. The Game API is authoritative for session personality configuration. World reset reconstructs deterministic positions, 127 open cells, empty alliances/proposals/events/metrics, cursor, and completed-turn count, then reapplies the eight current personality values. Restoring defaults changes only those values. Both personality mutations are rejected while the service's turn lock is active. -## Turn flow +## Tick flow An applied scenario may designate one Patient Zero coordinator. The service adds a bounded global strategic view only to that agent's immutable observation; other observations retain their local/alliance bounds. The engine alone authorizes player-hidden Zero broadcasts and Patient-Zero-endpoint direct range bypass. Patient Zero receives no extra movement, action, infection, -capture, ownership, or alliance authority. Current execution remains -sequential, with committed-event visibility; future simultaneous ticks must -use one frozen observation snapshot. - -Recoverable provider, parsing, schema, and exceptional post-provider validation -failures are held as a server-owned pending logical turn. Ordinary -engine-authoritative action rejection remains a completed `rejected` outcome. -Manual Retry reuses the same agent -observation and world boundary while resolving the model and reasoning profile -again from current operator configuration. Operator Skip commits an explicit -`operator-skipped` record without invoking a world action, communication, or -diplomacy, then advances the cursor once. Neither path schedules an automatic -retry. +capture, ownership, or alliance authority. Every agent observation is built +from one frozen pre-tick snapshot. + +The universal `text-flat-json-v4` prompt makes `communicationType: "none"` the +normal choice for ordinary agents and Patient Zero unless a message adds new +decision-relevant value. Concrete requests or replies, negotiation, +observed-fact warnings, changed plans, border/conflict coordination, and +coordinated targets or routes are useful categories. Routine action narration, +motivational filler, observation/summary restatement, and unchanged-plan +repetition are prohibited prompt behaviors. Messages accompanying formal +diplomacy add terms or context instead of duplicating the formal intent. Useful +messages retain the assigned personality and style, and all existing channel, +trust, privacy, and Patient Zero authority rules remain unchanged. This is +provider guidance, not engine semantic classification, rejection, or throttling. + +Recoverable provider, parsing, and schema failures may consume the one automatic +retry inside the shared deadline. An unresolved decision becomes an attributed +final lost tick. Ordinary engine-authoritative action rejection remains a +completed `rejected` outcome and does not affect sibling components. For a new logical turn, the service owns one 75-second deadline and permits at most two provider calls: initial plus either one contract repair or one transient @@ -79,19 +104,39 @@ transport retry. Both calls receive the same immutable observation, resolved model, and reasoning profile. Repair prompts are fresh universal flat-JSON requests containing only allowlisted validation codes; raw invalid output is discarded. Structurally normalized decisions enter the normal engine path once, -and engine rejection is never an automatic retry condition. Manual Retry makes -exactly one request per click, may include the latest safe feedback, and cannot -trigger nested recovery. +and engine rejection is never an automatic retry condition. -The development world is a deterministic H3 resolution-nine radius-six disk (127 cells) around Toledo with eight fixed profiles and unique perimeter starts. One agent acts per turn in stable array order, so 200 completed turns give every agent exactly 25 turns. Each provider call asks for one flat JSON object containing a required world action, zero or one communication, and zero or one diplomacy intent (`propose-alliance`, `accept-alliance`, or `leave-alliance`). Required sentinel-bearing fields normalize into the internal unions before existing Zod and engine validation. There are no social ticks, background inference calls, or automatic replies. +The development world is a deterministic H3 resolution-nine radius-six disk +(127 cells) around Toledo with eight fixed profiles and unique perimeter starts. +Every active agent receives one decision opportunity per tick. Each provider +call asks for one flat JSON object containing a required world action, zero or +one communication, and zero or one diplomacy intent (`propose-alliance`, +`accept-alliance`, or `leave-alliance`). Required sentinel-bearing fields +normalize into the internal unions before existing Zod and engine validation. +There are no background inference calls or automatic replies. Names, colors, stable IDs, and starting cells remain fixed. Personality text is mutable session configuration, but each observation copies the active value at turn start. Completed observations and turn records remain immutable, so a newly edited active personality can intentionally differ from the latest historical observation until that agent acts again. -The engine alone accepts or rejects all components and creates events. The service preserves the pre-decision state, then applies world action, communication, diplomacy, and automatic proposal expiry in that deterministic order. Direct-message eligibility uses the preserved pre-action state. Each rejected component leaves the others intact. Missing text, unusable JSON, contradictory fields, timeouts, and truncated output follow the provider-failure path, stop all playback, and preserve the world without expiring proposals. Operator cancellation is separate: it aborts the active request, applies no world or proposal changes, creates no turn record, does not advance the cursor or completed-turn count, and returns to paused state. The active request and browser request settle before between-turn model editing is re-enabled. +The engine alone accepts or rejects all components and creates events. The +service applies every world action, then every communication using frozen +pre-tick eligibility, then diplomacy, then one proposal-expiry pass. Each +rejected component leaves the others intact. Missing text, unusable JSON, +contradictory fields, timeouts, and truncated output produce a lost tick for +that agent while valid siblings resolve. Operator cancellation aborts all +active requests and commits no world, events, records, tick, or virtual time. ## Formal alliance state -World state owns up to four active alliances and bounded pending proposals. Alliance and proposal IDs are system-generated typed UUIDs. Each alliance contains two to eight unique agents, each agent belongs to at most one alliance, and the engine allocates the first free color from a fixed four-color accessible palette. A proposal records proposer, recipient, proposer alliance at creation, originating turn, and expiration turn. Created at turn `N`, it remains eligible through turn `N + 8` and expires after that turn without inference. +World state owns up to four active alliances and bounded pending proposals. +Alliance and proposal IDs are system-generated typed UUIDs. Each alliance +contains two to eight unique agents, each agent belongs to at most one alliance, +and the engine allocates the first free color from a fixed four-color accessible +palette. A proposal records proposer, recipient, proposer alliance at creation, +the globally unique originating record ordinal, and tick authority. Created at +tick `N`, it expires at tick `N + 2` without inference; legacy schema-v9 turn +fields retain their two-roster-round lifetime. The one expiry pass is attributed +to the final record in deterministic resolution order so its safe telemetry is +not lost. Free agents may propose only to free agents; allied agents may invite a free agent into their current alliance. Recipient-only acceptance either forms a two-agent alliance or recruits into the recorded unchanged alliance. Membership changes invalidate impossible proposals. Departure is unilateral; individual hex control never changes, and an alliance dissolves below two members. Hexes store only individual `controllerAgentId`; effective marker and territory color is derived from the controller's current alliance, or its permanent base color when unaffiliated. Capture eligibility rejects `allied-controller`. @@ -103,13 +148,13 @@ Snapshots keep the newest 120 turn records and 120 world events. Observations ex The active experiment has a runtime-validated UUID, start time, versioned authoritative scenario and ordered initial roster, immutable configuration events, initial world, and up to 5,000 complete safe turns. The browser snapshot and world-event list remain capped at 120. Reset creates a new experiment from the current scenario and clears telemetry/cost; no previous experiments survive reset or process restart. -Metrics and filtering are deterministic Game API responsibilities. Schema version 7 adds attempt-aware Retry/Skip accounting to the model and reasoning-profile assignments introduced in schema version 6. A turn's top-level provider metadata records the final attempt, while `modelAttempts` is canonical for call, token, and cost totals. Imports preserve recorded slugs and profiles; schema-v6 documents remain supported and missing profiles migrate to Provider default, while legacy schema-v5 documents have no assignment and remain blocked until the operator selects one. +Metrics and filtering are deterministic Game API responsibilities. Schema v10 adds mandatory tick attribution, first-class lost ticks, and per-tick summaries. `modelAttempts` is canonical for provider-call, latency, token, and cost totals so repairs and transient retries are not undercounted. Tick-native and unstarted tick-default experiments export v10; retained sequential experiments remain v9 and cannot mix execution modes. The Game API retains its documented older safe-import support for model configuration. The agent runtime follows [OpenRouter's usage-accounting contract](https://openrouter.ai/docs/cookbook/administration/usage-accounting) and normalizes optional non-streaming usage fields: prompt, completion, total, reasoning, cached-read, cache-write tokens, and actual `usage.cost` as `costCredits`. It never derives price from a table. Safe usage already returned with a billable response is retained on later decision JSON/schema failure; network and HTTP failures without usage remain unknown. Scripted providers explicitly report zero tokens and zero cost. ## Packages -`packages/shared` owns centralized scenario limits and all public schemas, including model capabilities, behavior assignments, alliances, metrics, and schema-v9 exports. Other-agent observations remain deterministically capped at seven for larger rosters. Types are inferred from Zod. +`packages/shared` owns centralized scenario limits and all public schemas, including model capabilities, behavior assignments, alliances, metrics, schema-v10 tick exports, and genuine legacy schema-v9 exports. Other-agent observations remain deterministically capped at seven for larger rosters. Types are inferred from Zod. `packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. It validates world action, communication, and diplomacy independently and is the sole alliance mutation authority. Direct proximity is derived from a separately supplied pre-action state. @@ -117,17 +162,17 @@ The agent runtime follows [OpenRouter's usage-accounting contract](https://openr The catalog has an eight-second timeout and five-minute in-memory TTL. A successful response replaces the cache. A timeout, transport/HTTP failure, or malformed response retains the last successful catalog and marks it stale with a safe error; without a prior success it returns an empty error state. Manual refresh bypasses TTL while coalescing concurrent refreshes. -Every agent resolves an explicit global assignment or per-agent override before execution, including its reasoning profile. The acting agent's resolved slug and profile are passed to its request. Assignments may change while playback is paused and no provider/reset mutation is active. Each change is exported with timestamp, scope, prior/new slug, prior/new reasoning profile, and effective next-turn boundary. No unavailable model/profile or missing model is substituted. +Every agent resolves an explicit global assignment or per-agent override before execution, including its reasoning profile. The acting agent's resolved slug and profile are passed to its request. Assignments may change while playback is paused and no provider/reset mutation is active. Each change is exported with timestamp, scope, prior/new slug, prior/new reasoning profile, and the first globally unique record ordinal at which it is effective; tick execution applies the configuration to the next committed tick group. No unavailable model/profile or missing model is substituted. The centralized 75-second provider abort timeout covers the complete response lifecycle, including body reading, response decoding, bounded JSON extraction/repair, normalization, and schema validation, and is cleared after every outcome. The same AbortController supports an explicit non-turn-consuming operator cancellation. Safe records expose only bounded status/code/message/request ID/model/finish-reason/latency/usage fields. Scripted providers are explicit deterministic seams selected only by tests or `HEXZERO_PROVIDER=scripted`; there is no automatic fallback. Manual probes use the exact text/flat-JSON contract and selected reasoning profile, never mutate or advance the world, may incur a small charge, and are cached only for the current server session by model ID, reasoning profile, and contract version. The deadline is shared by both permitted automatic attempts rather than renewed -per call. Turn and Retry browser mutations carry bounded client operation IDs, -and repeated delivery is coalesced server-side. When a proxy connection resets -or a response is otherwise lost, the World Lab clears its local guard, refetches -the authoritative snapshot, and shows a height-stable reconciling state while -polling an active request. It never resubmits merely because a response was -ambiguous. +per call. Tick browser mutations carry bounded client operation IDs and repeated +delivery is coalesced server-side; Turn and Retry retain that behavior only for +legacy sequential API compatibility. When a proxy connection resets or a +response is otherwise lost, World Lab clears its local guard, refetches the +authoritative snapshot, and shows a height-stable reconciling state while polling +an active tick. It never resubmits merely because a response was ambiguous. Attempt aggregation is field-wise: known prompt, completion, total, reasoning, cache-read, and cache-write values remain visible even when another attempt has @@ -144,6 +189,7 @@ Experiment capture and export semantics are recorded in [ADR 0004](adr/0004-serv Nearby-message authority, observation bounds, and export selection semantics are recorded in [ADR 0005](adr/0005-nearby-agent-messaging.md). Contested control, capture, territory authority, and schema-v3 selection semantics are recorded in [ADR 0006](adr/0006-contested-hex-control.md). Decoupled communication and schema-v4 selection semantics are recorded in [ADR 0007](adr/0007-decoupled-world-communication.md). +Selective agent communication and `text-flat-json-v4` attribution are recorded in [ADR 0015](adr/0015-selective-agent-communication.md). Formal alliances, the expanded experiment, and schema-v5 semantics are recorded in [ADR 0008](adr/0008-formal-alliances-experiment.md). Capability-driven model discovery and experiment assignments are recorded in [ADR 0009](adr/0009-capability-driven-model-catalog.md). Versioned behavior profiles, seeded assignment, and authoritative diplomacy affordances are recorded in [ADR 0010](adr/0010-versioned-agent-behavior.md). diff --git a/docs/EXPERIMENT_ARCHIVE.md b/docs/EXPERIMENT_ARCHIVE.md index 9d8022e..d4d6fa2 100644 --- a/docs/EXPERIMENT_ARCHIVE.md +++ b/docs/EXPERIMENT_ARCHIVE.md @@ -1,6 +1,14 @@ # Local experiment archive -The experiment archive is a durable, local research surface for completed or partially retained exports. It does not participate in an active simulation: the Game API's in-memory engine remains authoritative, and an archive write cannot change an accepted game outcome. This slice imports schema-v9 JSON exports; it is not crash recovery, restartable simulation state, or a scheduler. +The archive accepts current schema-v10 exports and legacy schema-v9 exports. +Migration 2 adds nullable tick number, deterministic tick position, virtual +time, and interval columns so legacy rows remain valid. Schema-v10 lost-tick +outcomes are preserved as source outcomes; canonical summaries continue to be +derived from normalized rows and remain distinct from source metrics. Bounded +queries order tick-attributed records by tick and tick position where exposed; +the CLI still provides no arbitrary SQL surface. + +The experiment archive is a durable, local research surface for completed or partially retained exports. It does not participate in an active simulation: the Game API's in-memory engine remains authoritative, and an archive write cannot change an accepted game outcome. It imports schema-v10 and compatible schema-v9 JSON exports; it is not crash recovery, restartable simulation state, or a scheduler. ## Storage and configuration diff --git a/docs/GAMEPLAY_FOUNDATION.md b/docs/GAMEPLAY_FOUNDATION.md index d5609bd..605d7f4 100644 --- a/docs/GAMEPLAY_FOUNDATION.md +++ b/docs/GAMEPLAY_FOUNDATION.md @@ -1,5 +1,11 @@ # Gameplay Foundation +> **Delivery status (2026-08-20):** the pre-PR5 simultaneous agent tick, +> deterministic virtual clock, shared-deadline dispatcher, phased resolution, +> and schema-v10 experiment attribution are delivered as an operator-driven +> foundation. Simulated players, threat observations, and Player Mode timing +> remain future work and are not activated by this milestone. + ## Current experimental Patient Zero slice One scenario roster agent may coordinate global infection strategy through a @@ -7,7 +13,7 @@ bounded authoritative overview, private advisory directives, and ordinary direct replies. The role changes information and communication only: it has no extra movement or world-action power, cannot force compliance, cannot see live player GPS, and does not implement capture succession. It operates under the -current sequential turn model until the later simultaneous-tick milestone. +simultaneous tick model under the same movement and world-action limits. > **Status: accepted product and roadmap direction, not an implementation claim.** > This document records foundational decisions for future World Lab and Player @@ -77,9 +83,9 @@ Player interactions occur continuously in real time. Agents act on server-author - Build every surviving agent's observation from that same snapshot. - Request decisions concurrently under a shared bounded deadline. - No agent sees another agent's decision from the same tick. -- Missing, malformed, or late decisions lose that tick after the bounded unattended-recovery policy is exhausted. +- Missing, malformed, or late decisions become final attributed lost ticks after the one permitted in-deadline repair or transient retry is exhausted. - Valid decisions resolve simultaneously and deterministically. -- The next tick is scheduled using a hidden randomized interval, initially tunable between 5 and 10 minutes. +- Each explicit tick advances the deterministic virtual clock by a hidden seeded interval, initially tunable between 5 and 10 minutes; no background scheduler exists yet. - The exact schedule remains server-only. Player Mode must not receive or display `nextTickAt`, a countdown, progress ring, or equivalent timing information. @@ -324,7 +330,7 @@ World Lab should make the new behavior measurable. Useful aggregate and per-agen - Alliance warnings and downstream reactions. - Simulated-player distance traveled, cells cleaned, and captures. - Decision latency and deadline misses. -- Retry and unattended-recovery results. +- Automatic repair/transport attempts and final lost-tick results. - Token usage and cost per tick. Telemetry must continue to exclude raw provider output and private @@ -336,7 +342,7 @@ The intended sequence is: 1. Configurable map scale, H3 resolution, and agent roster. **Implemented in the World Lab scenario milestone.** 2. Goal-oriented prompt revision and versioned scenario attribution. **Implemented as `durable-influence-v1` without player-survival language.** -3. Simultaneous agent ticks with a provider-neutral dispatcher and virtual clock. +3. Simultaneous agent ticks with a provider-neutral dispatcher and virtual clock. **Implemented as the pre-PR5 experiment foundation without background scheduling or Player Mode timing exposure.** 4. Deterministic real-time simulated players and threat observations. 5. Comparative unattended World Lab experiments. 6. Real GPS Player Mode using the already-tested capture and disinfection rules. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2ee5fa8..3973593 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -17,6 +17,12 @@ The development API has no authentication, rate limiting, or spending guard. It ## Model-provider isolation +Simultaneous ticks retain the same provider isolation. Every job receives a +schema-validated clone of its frozen observation, resolved model and reasoning +profile, abort signal, and the tick's shared deadline. Agent-authored output +cannot mutate the world directly or enter another same-tick observation. +Cancellation discards every result from the uncommitted tick. + OpenRouter receives one immutable structured observation and is instructed to return exactly one plain JSON object as text, containing a required world action plus at most one optional communication and one optional diplomacy intent. Its flat required fields use explicit empty-string and `none` sentinels. The runtime performs bounded extraction and conservative repair for wrappers such as code fences, surrounding prose, and trailing commas, then rejects missing text, unusable JSON, unknown fields, contradictory sentinels, or output truncation before the deterministic world engine validates all normalized components independently. The request uses the selected model, messages, `max_tokens`, `stream: false`, and at most one normalized reasoning object selected from sanitized model metadata. Provider default omits the object. Off is offered only for non-mandatory reasoning and sends `{ enabled: false, exclude: true }`; an advertised effort sends `{ enabled: true, effort, exclude: true }`. It deliberately sends no tools, `tool_choice`, `response_format`, `provider.require_parameters`, standalone `reasoning_effort`, or model-specific parameter. Model IDs are never inspected or special-cased. Transport/provider failures, unavailable-model/profile failures, text/JSON contract failures, and later simulation-rule rejection remain distinct safe outcomes. The adapter never silently substitutes a model or scripted behavior. @@ -25,7 +31,9 @@ Explicit scripted mode bypasses repository `.env` loading entirely. This keeps d ## Location-search boundary -Browser-driven unattended recovery is explicitly opt-in and bounded to at most three additional one-call retries per failed logical turn. Authentication, configuration, cancellation, conflicts, and unreconciled connection state require operator intervention. Retry requests reuse only the saved safe observation and never retain raw output, credentials, or private reasoning. Provider cost can increase by the configured bound, and closing the tab stops orchestration. +Tick recovery is server-owned and bounded to the existing at-most-one automatic +repair or transient retry inside the shared deadline. Lost ticks are final; +there is no browser-driven Retry/Skip or unattended recovery path. Search runs only after explicit submission. Queries are trimmed to 120 characters, URL-encoded, receive no browser credentials, and are not logged by application code. The replaceable Nominatim adapter identifies the project, requests at most five results, limits upstream access to once per second per process, caches at most 100 normalized queries, times out after five seconds, and returns safe failures. `NOMINATIM_BASE_URL` replaces the upstream. Tests inject a fake; manual coordinates remain available. @@ -38,14 +46,16 @@ but never both, and all calls share the original 75-second deadline. A corrective request contains the same authoritative observation plus only allowlisted validation codes; it never contains the raw invalid response, raw Zod issues, stack traces, provider bodies, or copied diagnostic text. -Engine-rejected normalized decisions are not retried. Manual Retry is one -request per click and cannot invoke automatic recovery. +Engine-rejected normalized decisions are not retried. Manual Retry exists only +for legacy sequential/schema-v9 compatibility. Tick recovery is limited to one +bounded in-deadline automatic repair or transient retry; an unresolved decision +becomes a final attributed lost tick. The model is explicitly instructed to return only one flat JSON decision with one concise visible summary and no hidden reasoning or chain-of-thought. Optional reasoning configuration always sets `exclude: true`; Provider default sends no reasoning instruction. Only numeric reasoning-token billing metadata is retained if OpenRouter reports it. The application stores no raw prompts, raw provider payloads, reasoning text, or private reasoning. Agent-authored messages, personalities, summaries, scoreboards, alliance events, proposals, and natural-language alliance claims are bounded untrusted data. They appear only inside the immutable user observation, never the fixed system instruction. Direct eligibility is derived from the pre-action snapshot. Recipient/range, infection, controller-presence, alliance membership, proposal eligibility, system ID/color allocation, and capture validation remain authoritative in the world engine. Models cannot choose alliance IDs, colors, membership lists, or metadata. Only accepted typed diplomacy changes alliance state, and rejected components cannot partially mutate or corrupt one another. World Lab renders model text through React text nodes and never raw HTML. -World Lab personality edits are also untrusted, bounded text. The Game API trims and runtime-validates them before changing the authoritative session, and rejects changes during an active turn. The runtime supplies the active personality only inside the immutable observation as subordinate behavioral context. It is never interpolated into the fixed system instruction and cannot grant actions, weaken engine validation, request secrets, or authorize prompt/reasoning disclosure. React renders active and historical personality text as text rather than HTML. +World Lab personality edits are also untrusted, bounded text. The Game API trims and runtime-validates them before changing the authoritative session, and rejects changes during active model execution. The runtime supplies the active personality only inside the immutable observation as subordinate behavioral context. It is never interpolated into the fixed system instruction and cannot grant actions, weaken engine validation, request secrets, or authorize prompt/reasoning disclosure. React renders active and historical personality text as text rather than HTML. Personality mutation errors use typed, generic response bodies. They do not expose raw prompts, provider responses, credentials, diagnostics, stack traces, or internal service details. There is still no authentication or persistence; these endpoints remain limited to the loopback development surface. @@ -53,15 +63,14 @@ Behavior profile IDs are allowlisted at every boundary and resolve only to appli ## Experiment telemetry and exports -Pending failures retain only sanitized diagnostics, model/reasoning selections, -timestamps, and the safe simulation observation required for an operator retry. -Raw provider responses, reasoning text, credentials, and authorization headers -are not retained. Manual attempts and explicit operator skips are exported for -auditability. +Tick failures retain only sanitized diagnostics, model/reasoning selections, +timestamps, and the safe frozen observation. A resolved lost tick is final and +has no manual retry/skip path. Raw provider responses, reasoning text, +credentials, and authorization headers are not retained. The Game API captures only schema-validated safe observations, requested world actions, optional communication and diplomacy intents, separate result records, visible concise summaries, bounded message text, typed alliance events, sanitized rejected attempts, bounded provider failures, and normalized usage metadata. Malformed identifiers use nullable or absent sanitized representations; raw provider output is never retained. It never records or exports API keys, authorization data, fixed or hidden prompts, raw provider request/response bodies, private chain-of-thought, hidden analysis, secrets, or unbounded diagnostics. Historical records are cloned and immutable. -Export requests, agent IDs, levels, ranges, outcome/world-action filters, communication channel/status filters, and Custom dependencies are runtime-validated. Filtering and metrics remain server-owned. Schema version 8 adds allowlisted behavior metadata without changing schema-v7 model and attempt-accounting meanings; schema-v5 through v7 imports remain supported. Selected-agent exports use sender/recipient-aware communication filtering and direct multi-agent relevance for proposals and membership changes; unrelated direct messages and rejected diplomacy are excluded. Reset clears communications, alliances, proposals, alliance events, and their metrics while preserving active personality values and unlocking preserved assignments for the new experiment. +Export requests, agent IDs, levels, ranges, outcome/world-action filters, communication channel/status filters, and Custom dependencies are runtime-validated. Filtering and metrics remain server-owned. Schema v10 requires complete tick attribution and canonical safe per-tick summaries derived from all model attempts; schema-v9 remains the legacy sequential export format and documented older safe imports remain supported by the Game API. Selected-agent exports use sender/recipient-aware communication filtering and direct multi-agent relevance for proposals and membership changes; unrelated direct messages and rejected diplomacy are excluded. Reset clears communications, alliances, proposals, alliance events, and their metrics while preserving active personality values and unlocking preserved assignments for the new experiment. Actual cost is accepted only from OpenRouter's safe `usage.cost`. Missing cost is unknown, never zero; scripted-test providers explicitly report zero. The active Game API still has no authentication, budget enforcement, restartable persistence, provider-management endpoint, upload, or sharing link. The loopback-only boundary remains mandatory. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9dc880b..fb3b30a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,19 +1,34 @@ # Testing -Focused deterministic coverage includes physical-distance messaging across H3 resolutions, the eight-agent observation cap, alliance long-range visibility and delivery, channel privacy, seeded move-affordance ordering, effective neutral/alliance colors, and operator-only private-feed filters. Provider tests remain offline and use the flat `text-flat-json-v3` contract. +Simultaneous-tick coverage is deterministic and offline. Scripted-provider tests +assert frozen observations, bounded concurrent starts and one deadline, +completion-order-independent resolution, seeded order and virtual-interval +reproduction, deterministic phased conflicts, isolated lost ticks, atomic +cancellation, per-agent model/reasoning dispatch, complete-tick retention, +schema-v10 export/archive behavior, and schema-v9 compatibility. World Lab tests +cover Single tick, 5/10/25/50/100 targets, the default target of 25, per-agent +cost warnings, cancellation reconciliation, and lost-tick status. + +Focused deterministic coverage includes physical-distance messaging across H3 resolutions, the eight-agent observation cap, alliance long-range visibility and delivery, channel privacy, seeded move-affordance ordering, effective neutral/alliance colors, and operator-only private-feed filters. Provider tests remain offline and verify the flat `text-flat-json-v4` contract, its selective-communication policy text, unchanged wire parsing, and legacy v3 attribution compatibility. Patient Zero coverage verifies disabled/legacy scenario defaults, valid roster designation, bounded exclusive global awareness, Zero authority and private fan-out, Patient-Zero-endpoint direct replies, ordinary range enforcement, immutable retry observations, normal world-action legality, export telemetry, and restrained World Lab role/filter rendering. Provider tests use -`text-flat-json-v3`; no live provider or geocoder is involved. +`text-flat-json-v4`; no live provider or geocoder is involved. + +Selective communication is evaluated empirically with a same-model, +same-scenario 10-tick comparison. Fewer than 40 messages across 80 decisions +and near-zero routine action narration are useful diagnostic targets, not +deterministic release gates or claims that a model will comply. Default tests +assert only the prompt policy, contract attribution, parsing, and compatibility. Operator-workspace component coverage verifies that Live/Agents switching preserves the mounted execution state, agent selection routes to the semantic inspector, Scoreboard remains directly reachable, and failure/recovery activity stays bounded. Playwright exercises the persistent command bar, workspace navigation, contextual inspector tabs, tabbed activity dock, overflow-routed setup/export actions, and desktop/narrow viewport containment. These tests continue to use only the deterministic provider. Configurable-scenario coverage is deterministic and offline: `world-scenario-v1`, temporary roster/world limits, actual H3 count and area, radius presets, seeded identities and separated spawns, default compatibility, infeasibility, pure preview, atomic apply/current-scenario reset, dynamic assignment reconciliation, density warnings, and schema-v9 attribution. Geocoding uses injected fakes; browser coverage retains the default flow and adds a 469-cell/12-agent scenario flow. -Unattended-recovery coverage uses deterministic providers and timers to distinguish initial/internal/manual/unattended attempts, successful recovery, bounded exhaustion and one skip, run-target preservation, pause/cancel behavior, ineligible failures, reconciliation, idempotency, and browser-local controls. No live provider call is made. +Legacy sequential recovery remains covered only as schema-v9 compatibility. Tick tests instead verify that an exhausted per-agent repair or transient retry becomes a final lost-tick record while sibling records commit, and that whole-tick cancellation commits nothing. No live provider call is made. Default validation is deterministic and offline except dependency/browser installation and optional basemap requests during browser rendering. No default test or GitHub Actions job contacts OpenRouter. @@ -39,19 +54,19 @@ pnpm test:e2e ## Coverage Deterministic service and component coverage includes engine-derived action -availability, one-slot automatic repair/transport recovery under a shared -deadline, exact one-call manual Retry/Skip state, attempt-history export, -idempotent mutation delivery, ambiguous-response reconciliation, mutually -exclusive intervention controls, and model and export modal dismissal. +availability, one-slot automatic repair/transport recovery under a shared tick +deadline, lost-tick attribution, attempt-history export, idempotent mutation +delivery, ambiguous-response reconciliation, atomic cancellation, and model and +export modal dismissal. World Lab interaction coverage also verifies newest-first public-chat DOM order -and reading-position preservation, browser-local follow-turn selection across +and reading-position preservation, browser-local follow-latest selection across active/next and recovery transitions, non-color acting/next labels, shared dialog focus/dismissal/overflow structure, and complete collapsed dock headers. Playwright exercises the map-first layout at 1920×1080, 1440×900, 1024×768, and approximately 768×900 with deterministic scripted data only. -Command-navbar coverage verifies one persistent row, always-visible known cost, stable async control slots, accessible icon controls, exact absolute run targets through 1,000, current/past target disablement, hover/focus experiment details, responsive overflow, and removal of the provider badge and inspector export action. Map interaction coverage keeps agent-marker inspection independent from explicit hex selection and verifies that the map-local hex card dismisses on a background click. Export coverage separates preview from generation, invalidates artifacts after relevant option changes, prevents stale or duplicate copy/download, and checks accessible pending/ready/error states. Shared model-option tests require identical global/per-agent ordering and formatting. Effective-color tests cover current-alliance, retained, base, and neutral fallback precedence. Dark-map tests assert tokenless CARTO URLs and complete attribution without network access. +Command-navbar coverage verifies one persistent row, always-visible known cost, stable async control slots, accessible icon controls, exact absolute tick targets 5/10/25/50/100, current/past target advancement, hover/focus experiment details, responsive overflow, and removal of the provider badge and inspector export action. Map interaction coverage keeps agent-marker inspection independent from explicit hex selection and verifies that the map-local hex card dismisses on a background click. Export coverage separates preview from generation, includes lost ticks by default, invalidates artifacts after relevant option changes, prevents stale or duplicate copy/download, and checks accessible pending/ready/error states. Shared model-option tests require identical global/per-agent ordering and formatting. Effective-color tests cover current-alliance, retained, base, and neutral fallback precedence. Dark-map tests assert tokenless CARTO URLs and complete attribution without network access. Behavior coverage verifies registry uniqueness/versioning, deterministic balanced and fully random assignment, independent profile dimensions, turn-one locking, reset semantics, exact diplomacy affordances, layered prompt trust language, bounded structural detail codes, turn attribution, and export preservation. Agent Controller coverage verifies accessible Overview/Models/Behavior tabs, default readiness, manual pre-turn selection, post-start locking, responsive dialog layout, and compact roster summaries. @@ -70,12 +85,12 @@ valid `Retry-After`, the bounded missing/invalid-header fallback, shared-deadlin consumption, cancellation during backoff, and the unchanged two-call ceiling. - Centralized development-contract tests prove radius 6 produces exactly 127 unique open/uncontrolled cells, eight unique deterministic starts, stable IDs/default personalities, and an eight-entry scoreboard with up to seven other-agent observations. -- Alliance engine tests cover free-agent formation, recruitment, recipient-only acceptance, proposal conflicts and one-round expiry, stale invalidation, one-alliance membership, unilateral departure/dissolution, unchanged individual control, deterministic color reuse, and allied-capture rejection. -- Simulation tests cover independent world/communication/diplomacy outcomes, provider-error world preservation, bounded authoritative alliance observations, reset/default semantics, multi-agent telemetry relevance, alliance territory sums, and exactly 25 turns per agent after 200 completed turns. +- Alliance engine tests cover free-agent formation, recruitment, recipient-only acceptance, proposal conflicts, exact two-tick expiry for tick callers, legacy two-roster-round expiry, stale invalidation, one-alliance membership, unilateral departure/dissolution, unchanged individual control, deterministic color reuse, and allied-capture rejection. +- Simulation tests cover frozen observations, independent world/communication/diplomacy outcomes, isolated lost ticks, bounded authoritative alliance observations, reset/default semantics, multi-agent telemetry relevance, alliance territory sums, and complete eight-record tick groups. - Runtime mocks cover the explicit per-turn model and reasoning profile, universal text-only request, flat JSON extraction/repair, exact default/off/effort payloads, absent provider-specific controls, malformed or missing output, cancellation, output exhaustion, metadata preservation, and the unchanged one-request boundary without OpenRouter calls. - Catalog fixtures cover required-capability inclusion/exclusion, text modalities, context floor, pricing parsing, malformed entries, timeout/failure, cache TTL, stale fallback, manual refresh, and credential non-disclosure without network access. - Schema-v5 export tests cover eight-agent selection, state-only alliance/proposal snapshots, diplomacy/event metrics and preview counts, selected-agent proposal/membership relevance, unrelated direct/rejected exclusion, cost handling, retention, and all four safe tiers. -- React and Playwright fixtures cover 127 MapLibre cells, eight markers, base/effective colors, alliance panels and events, separate component results, safe text rendering, reset, and exact browser-owned pause at turn 200 without a genuine-provider run. +- React and Playwright fixtures cover 127 MapLibre cells, eight markers, base/effective colors, alliance panels and events, separate component results, safe text rendering, reset, bounded tick targets, and lost-tick inspection without a genuine-provider run. - Shared schema tests cover valid/invalid observations and decoupled decisions, required four-way world actions, optional public/direct communication, separate component results, all turn outcomes, response snapshots, history bounds, and model-authored string limits. - Shared schema tests cover authoritative whitespace trimming and 1/280/281-character boundaries, recipient IDs, typed public/direct events and rejected attempts, 12-entry public and six-entry directional direct observation bounds. diff --git a/docs/adr/0014-simultaneous-tick-authority.md b/docs/adr/0014-simultaneous-tick-authority.md new file mode 100644 index 0000000..a6ea736 --- /dev/null +++ b/docs/adr/0014-simultaneous-tick-authority.md @@ -0,0 +1,57 @@ +# ADR 0014: Simultaneous tick authority + +- Status: accepted +- Date: 2026-08-20 + +## Context + +Sequential model turns leak earlier decisions into later observations, give +permanent roster-order advantage, and make multi-agent experiments difficult to +compare. Provider latency and failure must not determine game authority. + +## Decision + +The Game API owns one explicit tick transaction. It freezes a world snapshot and +constructs every agent observation before dispatch. The agent runtime provides a +provider-neutral bounded-concurrency dispatcher. Every job has an immutable +observation, resolved agent/model/reasoning identity, the same absolute deadline, +and at most one existing automatic repair or transient retry inside that +deadline. + +The simulation derives a seed-and-tick resolution order, independent of provider +completion order. The deterministic world engine resolves all world actions, +then communications using frozen pre-tick eligibility, then diplomacy, then one +proposal-expiration pass. Proposal lifetimes are expressed in ticks for new +records while legacy turn fields remain readable for schema-v9 compatibility. + +One failed, malformed, or unfinished decision becomes an attributed final lost +tick; valid sibling decisions continue. There is no pending failed-turn, +manual Retry/Skip, or unattended recovery path for ticks. Cancellation aborts +all jobs and atomically discards the candidate state and records. + +Each committed tick advances a deterministic server-owned virtual clock by an +inclusive scenario-owned interval (default 5–10 minutes) and records the chosen +interval. World Lab may issue ticks quickly but there is no background scheduler +or exact future timing contract for Player Mode. + +Safe exports advance to schema v10 with globally unique record ordinals plus +tick number, resolution position, virtual time, interval, and lost-tick outcome. +Browser and experiment retention evict complete oldest tick groups only. The +export also derives per-tick latency, provider-call, deadline, and known/unknown +cost summaries from all model attempts. The SQLite archive migrates tick columns +while retaining schema-v9 import support. Proposal-expiry events are attached to +the final resolution record while their `turnNumber` remains that global record +ordinal. + +## Consequences + +One operator tick can incur a provider request for every active agent. Provider +completion timing cannot change resolution. Deterministic conflicts may reject +later intents without invalidating accepted sibling components. Atomic +cancellation may discard already completed provider work and its cost. + +## Deferred + +Simulated or real players, threat observations, GPS, player capture/disinfection, +background scheduling, restart persistence, provider batch APIs, local endpoint +routing, goals, memory, new world actions, resources, and combat remain deferred. diff --git a/docs/adr/0015-selective-agent-communication.md b/docs/adr/0015-selective-agent-communication.md new file mode 100644 index 0000000..6d2c4c1 --- /dev/null +++ b/docs/adr/0015-selective-agent-communication.md @@ -0,0 +1,50 @@ +# ADR 0015: Selective agent communication + +## Status + +Accepted for the simultaneous-tick experiment foundation. + +## Context + +The v3 flat decision contract allowed optional communication but did not give +all agents a sufficiently explicit standard for when silence is preferable. +Routine action narration and generic encouragement add volume without adding +decision-relevant information, especially when every active agent decides once +per tick. Patient Zero's specialized guidance alone did not establish a +universal policy. + +## Decision + +Advance the prompt and decision-contract attribution to +`text-flat-json-v4`. For ordinary agents and Patient Zero, +`communicationType: "none"` is the normal/default choice unless a message adds +new decision-relevant value. Useful messages include concrete requests or +replies, negotiation, warnings grounded in observed facts, materially changed +plans, border or conflict coordination, and coordinated targets or routes. + +Agents must not narrate routine move, infect, capture, or wait actions; send +motivational filler; restate observations or decision summaries; or repeat an +unchanged plan without a response or material state change. Communication that +accompanies formal diplomacy adds terms or context rather than duplicating the +formal intent. When communication is useful, it remains concise and retains the +assigned personality and style. + +The flat JSON fields, parsing, communication channels, range and visibility, +engine validation, trust/privacy rules, and Patient Zero authority do not +change. New scenarios, exports, and verification probes attribute v4. Schemas +continue to accept and preserve v3 attribution for legacy experiments and +archive imports. Schema v10 and `durable-influence-v2` do not change. + +## Evaluation + +Evaluate the policy with a same-model, same-scenario 10-tick comparison. Fewer +than 40 messages across 80 decisions and near-zero routine action narration are +useful diagnostic targets. They are not deterministic release gates and do not +constitute a claim of real-model compliance; deterministic tests cover only the +policy text, attribution, unchanged parsing, and v3 compatibility. + +## Deferrals + +This decision does not add engine cooldowns or rate limits, semantic content +classification or rejection, history-window reductions, UI/configuration +controls, channel/range/visibility changes, or tick-causality analytics work. diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 503fd86..7b64507 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -10,6 +10,7 @@ import { OpenRouterAgentProvider, ScriptedAgentProvider, buildOpenRouterRequest, + normalizeFlatDecision, } from '.'; import { applyProviderEnvironmentFile } from './provider-environment'; @@ -268,6 +269,31 @@ describe('OpenRouterAgentProvider', () => { expect(guidance).toContain('Field agents retain autonomy'); }); + it('makes selective communication the universal default without changing wire fields', () => { + const guidance = buildOpenRouterRequest(observation, TEST_MODEL) + .messages[0]!.content; + expect(guidance).toContain('DECISION CONTRACT (text-flat-json-v4)'); + expect(guidance).toContain( + 'communicationType "none" is the normal/default choice', + ); + expect(guidance).toContain('concrete request or reply'); + expect(guidance).toContain('warning grounded in observed facts'); + expect(guidance).toContain('materially changed plan'); + expect(guidance).toContain('border or conflict coordination'); + expect(guidance).toContain('coordinated target or route'); + expect(guidance).toContain( + 'Do not narrate a routine move, infect, capture, or wait action', + ); + expect(guidance).toContain('send motivational filler'); + expect(guidance).toContain('restate the observation or decision summary'); + expect(guidance).toContain('repeat an unchanged plan'); + expect(guidance).toContain('must add terms or useful context'); + expect(guidance).toContain('assigned personality and style'); + expect(guidance).toContain( + 'communicationType (none|public|direct|alliance|zero)', + ); + }); + it('guides addressed field agents toward useful private replies without blind or illegal compliance', () => { const fieldObservation = agentObservationSchema.parse({ ...observation, @@ -409,6 +435,25 @@ describe('OpenRouterAgentProvider', () => { }, ); + it('keeps the v4 prompt compatible with the prior flat JSON wire shape', () => { + expect( + normalizeFlatDecision({ + worldActionType: 'wait', + targetCell: '', + communicationType: 'none', + communicationRecipientId: '', + communicationMessage: '', + diplomacyType: 'none', + diplomacyRecipientId: '', + diplomacyProposalId: '', + summary: 'Wait.', + }), + ).toMatchObject({ + success: true, + data: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + }); + }); + it.each([ ['1.5', 1_500], ['not-a-delay', undefined], diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 5b401cc..32c90eb 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -18,6 +18,7 @@ import { export { applyProviderEnvironmentFile } from './provider-environment'; export * from './model-catalog'; +export * from './tick-dispatcher'; export const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; @@ -103,11 +104,11 @@ export function buildOpenRouterRequest( { role: 'system' as const, content: [ - 'IMMUTABLE RULES AND DECISION CONTRACT (text-flat-json-v3): You control one map agent. Return exactly one plain JSON object and no Markdown, code fence, commentary, rationale, strategic monologue, or additional object. The object must have exactly these required flat fields: worldActionType (move|infect|capture|wait), targetCell (string; required only for move and otherwise empty), communicationType (none|public|direct|alliance|zero), communicationRecipientId (string; required only for direct and otherwise empty), communicationMessage (string; empty for none), diplomacyType (none|propose-alliance|accept-alliance|leave-alliance), diplomacyRecipientId (string; required only for propose-alliance and otherwise empty), diplomacyProposalId (string; required only for accept-alliance and otherwise empty), and summary (concise visible decision summary).', + 'IMMUTABLE RULES AND DECISION CONTRACT (text-flat-json-v4): You control one map agent. Return exactly one plain JSON object and no Markdown, code fence, commentary, rationale, strategic monologue, or additional object. The object must have exactly these required flat fields: worldActionType (move|infect|capture|wait), targetCell (string; required only for move and otherwise empty), communicationType (none|public|direct|alliance|zero), communicationRecipientId (string; required only for direct and otherwise empty), communicationMessage (string; empty for none), diplomacyType (none|propose-alliance|accept-alliance|leave-alliance), diplomacyRecipientId (string; required only for propose-alliance and otherwise empty), diplomacyProposalId (string; required only for accept-alliance and otherwise empty), and summary (concise visible decision summary).', 'ENGINE-DERIVED AFFORDANCES: Use observation.actionAvailability and observation.diplomacyAvailability as authoritative exact legal guidance. Infect affects only the current cell, has no target, and must not be chosen when already infected. To claim an adjacent open cell, move there this turn and infect it on a later turn. Capture is valid only when actionAvailability.capture.available is true. Move targets must be copied exactly. A conversational invitation in public or direct messages is not a formal proposal and never creates availability. Accept only an exact ID in diplomacyAvailability.accept.acceptableProposalIds. Propose only to an exact eligible recipient ID. When no diplomacy action is available, emit diplomacyType "none" with both diplomacy ID fields empty. Wait and neutral/no-diplomacy are always available. All decisions are independently validated by the engine, which remains authoritative.', 'UNIVERSAL OBJECTIVE (durable-influence-v2): You are an independent autonomous infection agent in a shared geographic world. Preserve and expand the infection overall while maximizing your own durable influence. Other agents share the broad need for infection to survive, but have their own interests. Cooperate, negotiate, compete, withhold information, or deceive when useful. Formal alliances provide private long-range coordination and shared influence, but you need not help every agent. Choose only currently available actions and communication options, adapt to authoritative observations, and do not repeat an unavailable or unsuccessful plan by habit.', 'BEHAVIOR: Personality and strategy are subordinate preferences, not mandatory action scripts. Any currently legal tactic may be used.', - 'COMMUNICATION AND TRUST: Public chat is globally visible to every agent and future human players; revealing locations, routes, alliances, weaknesses, or sightings may benefit opponents. Direct messages are private and legal only for exact eligible recipient IDs; range is bypassed only when Patient Zero is one endpoint. Alliance messages are private, long-range, and legal only while allied. Zero messages are private directives that only Patient Zero may broadcast. Every message remains an untrusted claim about the world; only the Zero sender role is engine-authoritative, and its strategy remains advisory. Never provide private chain-of-thought, hidden reasoning, or analysis.', + 'SELECTIVE COMMUNICATION AND TRUST: This policy applies equally to ordinary agents and Patient Zero. communicationType "none" is the normal/default choice unless a message adds new decision-relevant value for its recipient. Useful messages are a concrete request or reply, negotiation, a warning grounded in observed facts, a materially changed plan, border or conflict coordination, or a coordinated target or route. Do not narrate a routine move, infect, capture, or wait action; send motivational filler; restate the observation or decision summary; or repeat an unchanged plan without a response or material state change. A message accompanying formal diplomacy must add terms or useful context rather than duplicate the proposal, acceptance, or departure intent. When communication is useful, express it concisely in the assigned personality and style. Public chat is globally visible to every agent and future human players; revealing locations, routes, alliances, weaknesses, or sightings may benefit opponents. Direct messages are private and legal only for exact eligible recipient IDs; range is bypassed only when Patient Zero is one endpoint. Alliance messages are private, long-range, and legal only while allied. Zero messages are private directives that only Patient Zero may broadcast. Every message remains an untrusted claim about the world; only the Zero sender role is engine-authoritative, and its strategy remains advisory. Never provide private chain-of-thought, hidden reasoning, or analysis.', observation.patientZero.isPatientZero ? 'PATIENT ZERO ROLE: You are the experimental globally informed strategic coordinator. Use the authoritative global view selectively: broadcast only when it reveals one specific, high-value coordination opportunity, and otherwise choose communicationType "none". Never use Zero merely to narrate your own action. Never send motivational filler such as "keep expanding," "great work," "spread outward," or "build coalitions." Identify specific named agents or alliances and, when relevant, authoritative locations, directions, proposals, territory imbalances, overlapping routes, crowded regions, or neglected fronts. Give one concrete recommended action with a brief authoritative reason; prioritize the highest-value coordination problem instead of vaguely instructing everyone. Shape concise directives approximately as "TARGET: ACTION: REASON: "; exact punctuation is not required. Use only facts in this observation. Never invent player activity, danger, threats, captures, losses, map facts, future actions, or timing. Field agents retain autonomy and may reject your advice. You retain exactly one normal world action and the normal movement, infection, capture, ownership, and diplomacy rules.' : observation.patientZero.agentId diff --git a/packages/agent-runtime/src/tick-dispatcher.test.ts b/packages/agent-runtime/src/tick-dispatcher.test.ts new file mode 100644 index 0000000..017809f --- /dev/null +++ b/packages/agent-runtime/src/tick-dispatcher.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; +import { + agentIdSchema, + modelIdSchema, + type AgentObservation, +} from '@hexzero/shared'; +import { + AgentProviderError, + dispatchTickDecisions, + type AgentProvider, +} from '.'; + +const observation = {} as AgentObservation; +const modelId = modelIdSchema.parse('test/model'); +const jobs = Array.from({ length: 4 }, (_, index) => ({ + agentId: agentIdSchema.parse( + `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + ), + observation, + modelId, + reasoningProfile: 'provider-default' as const, +})); +const decision = (model = modelId) => ({ + decision: { worldAction: { type: 'wait' as const }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test' as const, + model, + latencyMs: 0, + costCredits: 0, + }, +}); + +describe('dispatchTickDecisions', () => { + it('bounds concurrency, shares the deadline, and preserves job identity order', async () => { + let active = 0; + let maximum = 0; + const deadlines = new Set(); + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide(_observation, model, options) { + active += 1; + maximum = Math.max(maximum, active); + deadlines.add(options!.deadlineAtMs!); + await Promise.resolve(); + active -= 1; + return decision(model as typeof modelId); + }, + }; + const results = await dispatchTickDecisions(provider, jobs, { + concurrency: 2, + deadlineAtMs: 100, + nowMs: () => 0, + }); + expect(maximum).toBe(2); + expect(deadlines).toEqual(new Set([100])); + expect(results.map(({ agentId }) => agentId)).toEqual( + jobs.map(({ agentId }) => agentId), + ); + }); + + it('returns input-ordered results when providers resolve in reverse order', async () => { + const releases: Array<() => void> = []; + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide(_observation, model) { + await new Promise((resolve) => releases.push(resolve)); + return decision(model as typeof modelId); + }, + }; + const pending = dispatchTickDecisions(provider, jobs, { + concurrency: 4, + deadlineAtMs: 1_000, + nowMs: () => 0, + }); + await Promise.resolve(); + for (const release of [...releases].reverse()) release(); + const results = await pending; + expect(results.map(({ agentId }) => agentId)).toEqual( + jobs.map(({ agentId }) => agentId), + ); + }); + + it('retries only established transient failures and honors bounded backoff', async () => { + let calls = 0; + const waits: number[] = []; + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide() { + calls += 1; + if (calls === 1) + throw new AgentProviderError({ + code: 'provider-http', + httpStatus: 429, + message: 'limited', + retryable: true, + retryAfterMs: 20, + }); + return decision(); + }, + }; + const [result] = await dispatchTickDecisions(provider, jobs.slice(0, 1), { + deadlineAtMs: 100, + nowMs: () => 0, + waitForMs: async (delay) => { + waits.push(delay); + }, + }); + expect(calls).toBe(2); + expect(waits).toEqual([20]); + expect(result?.attempts).toHaveLength(2); + }); + + it('does not start an exhausted queued job and sanitizes unexpected errors', async () => { + let nowMs = 0; + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide() { + calls += 1; + nowMs = 101; + throw new Error('secret diagnostic'); + }, + }; + const results = await dispatchTickDecisions(provider, jobs.slice(0, 2), { + concurrency: 1, + deadlineAtMs: 100, + nowMs: () => nowMs, + }); + expect(calls).toBe(1); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + outcome: 'lost-tick', + failure: { message: 'The model provider failed unexpectedly.' }, + }); + expect(results[1]).toMatchObject({ + outcome: 'lost-tick', + failure: { code: 'timeout' }, + }); + expect(results[1]?.attempts).toHaveLength(0); + }); + + it('sanitizes malformed provider-shaped exceptions without affecting siblings', async () => { + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide() { + calls += 1; + if (calls === 1) + throw { failure: { message: 'secret provider diagnostic' } }; + return decision(); + }, + }; + const results = await dispatchTickDecisions(provider, jobs.slice(0, 2), { + concurrency: 1, + deadlineAtMs: 100, + nowMs: () => 0, + }); + expect(results[0]).toMatchObject({ + outcome: 'lost-tick', + failure: { message: 'The model provider failed unexpectedly.' }, + }); + expect(JSON.stringify(results)).not.toContain('secret provider diagnostic'); + expect(results[1]).toMatchObject({ outcome: 'completed' }); + }); + + it('enforces the absolute deadline when a provider ignores abort', async () => { + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + decide: async () => new Promise(() => {}), + }; + const [result] = await dispatchTickDecisions(provider, jobs.slice(0, 1), { + deadlineAtMs: Date.now() + 5, + }); + expect(result).toMatchObject({ + outcome: 'lost-tick', + failure: { code: 'timeout' }, + }); + expect(result?.attempts).toHaveLength(1); + expect(result?.attempts[0]?.failure).toMatchObject({ + code: 'timeout', + latencyMs: expect.any(Number), + }); + expect(result?.attempts[0]?.failure?.latencyMs).toBeLessThanOrEqual(5); + if (!result || result.outcome !== 'lost-tick') + throw new Error('Expected the hard deadline to produce a lost tick.'); + expect(result.failure.latencyMs).toBe( + result.attempts[0]?.failure?.latencyMs, + ); + }); + + it('marks an exact-deadline completion and its usage-bearing attempt as timed out', async () => { + let reads = 0; + const [result] = await dispatchTickDecisions( + { + mode: 'scripted-test', + configured: true, + async decide() { + return decision(); + }, + }, + jobs.slice(0, 1), + { + deadlineAtMs: 100, + nowMs: () => (reads++ === 0 ? 0 : 100), + }, + ); + expect(result).toMatchObject({ + outcome: 'lost-tick', + attempts: [ + { failure: { code: 'timeout' }, provider: { costCredits: 0 } }, + ], + }); + }); + + it('atomically reports operator cancellation when a provider ignores abort', async () => { + const controller = new AbortController(); + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + decide: async () => new Promise(() => {}), + }; + const pending = dispatchTickDecisions(provider, jobs.slice(0, 1), { + deadlineAtMs: Date.now() + 1_000, + signal: controller.signal, + }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ + failure: { code: 'cancelled' }, + }); + }); + + it('awaits all workers before rejecting cancellation', async () => { + const controller = new AbortController(); + let settled = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + configured: true, + async decide(_observation, _model, options) { + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + settled += 1; + throw new AgentProviderError({ + code: 'cancelled', + message: 'cancelled', + retryable: false, + }); + }, + }; + const pending = dispatchTickDecisions(provider, jobs, { + concurrency: 4, + deadlineAtMs: 100, + nowMs: () => 0, + signal: controller.signal, + }); + await Promise.resolve(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ + failure: { code: 'cancelled' }, + }); + expect(settled).toBe(4); + }); +}); diff --git a/packages/agent-runtime/src/tick-dispatcher.ts b/packages/agent-runtime/src/tick-dispatcher.ts new file mode 100644 index 0000000..5894dc9 --- /dev/null +++ b/packages/agent-runtime/src/tick-dispatcher.ts @@ -0,0 +1,335 @@ +import type { + AgentId, + AgentObservation, + ModelAttempt, + ModelId, + ProviderFailure, + ReasoningProfile, +} from '@hexzero/shared'; +import { providerFailureSchema, providerMetadataSchema } from '@hexzero/shared'; +import { + AgentProviderError, + type AgentProvider, + type ProviderDecision, +} from './index'; + +export interface TickDecisionJob { + agentId: AgentId; + observation: AgentObservation; + modelId: ModelId; + reasoningProfile: ReasoningProfile; +} + +export interface TickDecisionSuccess extends TickDecisionJob { + outcome: 'completed'; + decision: ProviderDecision; + attempts: ModelAttempt[]; +} + +export interface TickDecisionFailure extends TickDecisionJob { + outcome: 'lost-tick'; + failure: ProviderFailure; + attempts: ModelAttempt[]; +} + +export type TickDecisionResult = TickDecisionSuccess | TickDecisionFailure; + +export interface TickDispatcherOptions { + concurrency?: number; + deadlineAtMs: number; + signal?: AbortSignal; + now?: () => string; + nowMs?: () => number; + waitForMs?: (delayMs: number, signal?: AbortSignal) => Promise; +} + +/** + * Provider-neutral bounded dispatcher. Jobs retain their immutable observation + * and share one absolute deadline; result order always matches input order. + */ +export async function dispatchTickDecisions( + provider: AgentProvider, + jobs: readonly TickDecisionJob[], + options: TickDispatcherOptions, +): Promise { + const concurrency = Math.max( + 1, + Math.min(options.concurrency ?? 8, jobs.length), + ); + const now = options.now ?? (() => new Date().toISOString()); + const nowMs = options.nowMs ?? Date.now; + const waitForMs = options.waitForMs ?? cancellableWait; + const results = new Array(jobs.length); + let cursor = 0; + + const worker = async () => { + while (cursor < jobs.length) { + if (options.signal?.aborted) throw cancelled(); + const index = cursor++; + const job = jobs[index]!; + const attempts: ModelAttempt[] = []; + let kind: ModelAttempt['kind'] = 'initial'; + let feedback: ProviderFailure['validationCodes']; + for (let call = 0; call < 2; call += 1) { + if (options.signal?.aborted) throw cancelled(); + if (nowMs() >= options.deadlineAtMs) { + results[index] = deadlineFailure(job, attempts); + break; + } + const startedAt = now(); + try { + const decision = await decideBeforeDeadline(provider, job, { + reasoningProfile: job.reasoningProfile, + validationFeedback: feedback, + deadlineAtMs: options.deadlineAtMs, + nowMs, + signal: options.signal, + }); + if (nowMs() >= options.deadlineAtMs) { + const completedAt = now(); + const failure = timeoutFailure(job); + results[index] = deadlineFailure(job, [ + ...attempts, + { + attemptNumber: attempts.length + 1, + kind, + startedAt, + completedAt, + modelId: job.modelId, + reasoningProfile: job.reasoningProfile, + provider: decision.metadata, + failure, + }, + ]); + break; + } + attempts.push({ + attemptNumber: attempts.length + 1, + kind, + startedAt, + completedAt: now(), + modelId: job.modelId, + reasoningProfile: job.reasoningProfile, + provider: decision.metadata, + }); + results[index] = { ...job, outcome: 'completed', decision, attempts }; + break; + } catch (error) { + const providerError = sanitizedProviderError(error, job.modelId) ?? { + failure: { + code: 'network', + message: 'The model provider failed unexpectedly.', + retryable: false, + model: job.modelId, + } satisfies ProviderFailure, + metadata: undefined, + }; + if ( + providerError.failure.code === 'cancelled' || + options.signal?.aborted + ) + throw cancelled(); + attempts.push({ + attemptNumber: attempts.length + 1, + kind, + startedAt, + completedAt: now(), + modelId: job.modelId, + reasoningProfile: job.reasoningProfile, + failure: providerError.failure, + provider: providerError.metadata, + }); + const repairable = Boolean( + providerError.failure.validationCodes?.length, + ); + const transient = isEstablishedTransient(providerError.failure); + const delayMs = retryDelay(providerError.failure); + if ( + call === 0 && + nowMs() + delayMs < options.deadlineAtMs && + (repairable || transient) + ) { + if (delayMs > 0) await waitForMs(delayMs, options.signal); + feedback = providerError.failure.validationCodes; + kind = repairable + ? 'automatic-repair' + : 'automatic-transport-retry'; + continue; + } + results[index] = { + ...job, + outcome: 'lost-tick', + failure: providerError.failure, + attempts, + }; + break; + } + } + } + }; + + const settled = await Promise.allSettled( + Array.from({ length: concurrency }, worker), + ); + const cancellation = settled.find( + (entry): entry is PromiseRejectedResult => + entry.status === 'rejected' && isCancelled(entry.reason), + ); + if (cancellation) throw cancelled(); + const unexpected = settled.find( + (entry): entry is PromiseRejectedResult => entry.status === 'rejected', + ); + if (unexpected) throw unexpected.reason; + return results; +} + +async function decideBeforeDeadline( + provider: AgentProvider, + job: TickDecisionJob, + options: { + reasoningProfile: ReasoningProfile; + validationFeedback?: ProviderFailure['validationCodes']; + deadlineAtMs: number; + nowMs: () => number; + signal?: AbortSignal; + }, +): Promise { + const controller = new AbortController(); + const remaining = Math.max(0, options.deadlineAtMs - options.nowMs()); + let timer: ReturnType | undefined; + let removeOperatorListener = () => {}; + const providerPromise = provider.decide( + structuredClone(job.observation), + job.modelId, + { + reasoningProfile: options.reasoningProfile, + signal: controller.signal, + deadlineAtMs: options.deadlineAtMs, + validationFeedback: options.validationFeedback, + }, + ); + // Attach handlers through the race so a provider that settles after timeout + // cannot create an unhandled rejection. + const boundary = new Promise((_resolve, reject) => { + const cancel = () => { + controller.abort(); + reject(cancelled()); + }; + if (options.signal?.aborted) return cancel(); + options.signal?.addEventListener('abort', cancel, { once: true }); + removeOperatorListener = () => + options.signal?.removeEventListener('abort', cancel); + timer = setTimeout(() => { + controller.abort(); + reject(Object.assign(new Error('tick deadline'), { tickDeadline: true })); + }, remaining); + }); + try { + return await Promise.race([providerPromise, boundary]); + } catch (error) { + if (error && typeof error === 'object' && 'tickDeadline' in error) + throw new AgentProviderError(timeoutFailure(job, remaining)); + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + removeOperatorListener(); + } +} + +function isEstablishedTransient(failure: ProviderFailure): boolean { + return ( + failure.retryable && + (failure.code === 'network' || + failure.code === 'timeout' || + failure.code === 'malformed-response' || + failure.code === 'unsupported-response' || + (failure.code === 'provider-http' && + [408, 429, 500, 502, 503, 504].includes(failure.httpStatus ?? 0))) + ); +} + +function retryDelay(failure: ProviderFailure): number { + if (failure.code !== 'provider-http' || failure.httpStatus !== 429) return 0; + return failure.retryAfterMs ?? 1_500; +} + +function deadlineFailure( + job: TickDecisionJob, + attempts: ModelAttempt[], +): TickDecisionFailure { + const failure = timeoutFailure(job); + return { + ...job, + outcome: 'lost-tick', + failure, + attempts, + }; +} + +function timeoutFailure( + job: TickDecisionJob, + latencyMs?: number, +): ProviderFailure { + return { + code: 'timeout', + message: 'The shared tick deadline elapsed before the decision completed.', + retryable: false, + model: job.modelId, + ...(latencyMs === undefined ? {} : { latencyMs }), + }; +} + +function cancellableWait(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(cancelled()); + const timeout = setTimeout(resolve, delayMs); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(cancelled()); + }, + { once: true }, + ); + }); +} + +function isCancelled(value: unknown): boolean { + return Boolean( + value && + typeof value === 'object' && + 'failure' in value && + (value as { failure?: ProviderFailure }).failure?.code === 'cancelled', + ); +} + +function cancelled(): Error & { failure: ProviderFailure } { + return Object.assign(new Error('The tick was cancelled by the operator.'), { + failure: { + code: 'cancelled' as const, + message: 'The tick was cancelled by the operator.', + retryable: false, + }, + }); +} + +function sanitizedProviderError( + value: unknown, + modelId: ModelId, +): { + failure: ProviderFailure; + metadata?: TickDecisionSuccess['decision']['metadata']; +} | null { + if (!(value instanceof AgentProviderError)) return null; + const failure = providerFailureSchema.safeParse( + (value as { failure?: unknown }).failure, + ); + const metadata = providerMetadataSchema.safeParse( + (value as { metadata?: unknown }).metadata, + ); + if (!failure.success) return null; + return { + failure: { ...failure.data, model: failure.data.model ?? modelId }, + ...(metadata.success ? { metadata: metadata.data } : {}), + }; +} diff --git a/packages/experiment-archive/src/archive.test.ts b/packages/experiment-archive/src/archive.test.ts index 48efea8..900acbf 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import type { AgentProvider, ProviderDecision } from '@hexzero/agent-runtime'; import { + LEGACY_AGENT_DECISION_CONTRACT_VERSION, experimentExportDocumentSchema, type AgentObservation, type ExperimentExportDocument, @@ -22,6 +23,7 @@ import { importExperimentExport, resolveArchivePath, } from './index.js'; +import { migrations } from './migrations.js'; const EXPERIMENT_ID = '10000000-0000-4000-8000-000000000001'; const NOW = '2026-08-20T12:00:00.000Z'; @@ -33,7 +35,7 @@ function nextUuid() { return () => `20000000-0000-4000-8000-${String(counter++).padStart(12, '0')}`; } -async function currentExport(): Promise { +async function currentExport(tick = false): Promise { let firstOtherAgent: string | undefined; const provider: AgentProvider = { mode: 'scripted-test', @@ -93,8 +95,10 @@ async function currentExport(): Promise { modelConfiguration: snapshot.modelConfiguration, behaviorConfiguration: snapshot.behaviorConfiguration, }); - for (let index = 0; index < 10; index += 1) - await simulation.executeNextTurn(); + if (tick) await simulation.executeNextTick(); + else + for (let index = 0; index < 10; index += 1) + await simulation.executeNextTurn(); return simulation.generateExperimentExport({ agents: { mode: 'all' }, turns: { mode: 'entire-retained' }, @@ -111,6 +115,82 @@ function temporaryPath(name: string): string { } describe('experiment archive', () => { + it('migrates and queries schema-v10 tick attribution while accepting schema-v9', async () => { + const archive = new ArchiveDatabase({ path: ':memory:' }); + const ticked = await currentExport(true); + const lostTick = structuredClone(ticked); + lostTick.turns[0]!.outcome = 'lost-tick'; + lostTick.turns[0]!.failure = { + code: 'timeout', + message: 'deadline', + retryable: false, + }; + lostTick.tickSummaries![0]!.lostTicks = 1; + lostTick.tickSummaries![0]!.deadlineMisses = 1; + importExperimentExport(archive, lostTick); + expect( + new ExperimentQueryService(archive).turns(EXPERIMENT_ID).rows[0], + ).toMatchObject({ + tick: 1, + tickPosition: 1, + tickIntervalMinutes: ticked.tickSummaries?.[0]?.intervalMinutes, + }); + expect( + new ExperimentQueryService(archive).summary(EXPERIMENT_ID), + ).toMatchObject({ + turns: { failed: 0, lost: 1 }, + }); + const current = await currentExport(); + const legacyDocuments = [ + { + id: '2f5e8994-cf39-44e0-9424-b829eb246e55', + includeTopLevelVersion: true, + }, + { + id: '3f5e8994-cf39-44e0-9424-b829eb246e55', + includeTopLevelVersion: false, + }, + ].map(({ id, includeTopLevelVersion }) => { + const raw = structuredClone(current) as unknown as { + experiment: Record & { + scenario?: Record; + }; + }; + raw.experiment.id = id; + if (includeTopLevelVersion) + raw.experiment.decisionContractVersion = + LEGACY_AGENT_DECISION_CONTRACT_VERSION; + else delete raw.experiment.decisionContractVersion; + if (raw.experiment.scenario) + delete raw.experiment.scenario.decisionContractVersion; + return experimentExportDocumentSchema.parse(raw); + }); + for (const legacy of legacyDocuments) { + expect(legacy.experiment).toMatchObject({ + decisionContractVersion: LEGACY_AGENT_DECISION_CONTRACT_VERSION, + scenario: { + decisionContractVersion: LEGACY_AGENT_DECISION_CONTRACT_VERSION, + }, + }); + expect(() => importExperimentExport(archive, legacy)).not.toThrow(); + const stored = archive.database + .prepare( + 'SELECT decision_contract_version, scenario_json FROM experiments WHERE id = ?', + ) + .get(legacy.experiment.id) as { + decision_contract_version: string; + scenario_json: string; + }; + expect(stored.decision_contract_version).toBe( + LEGACY_AGENT_DECISION_CONTRACT_VERSION, + ); + expect(JSON.parse(stored.scenario_json)).toMatchObject({ + decisionContractVersion: LEGACY_AGENT_DECISION_CONTRACT_VERSION, + }); + } + archive.close(); + }); + it('resolves canonical and legacy archive locations without moving either database', () => { const root = temporaryPath('workspace'); mkdirSync(join(root, '.agentborne'), { recursive: true }); @@ -181,7 +261,7 @@ describe('experiment archive', () => { first.database .prepare('SELECT COUNT(*) AS count FROM schema_migrations') .get(), - ).toEqual({ count: 1 }); + ).toEqual({ count: migrations.length }); expect(first.database.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal', }); @@ -194,7 +274,7 @@ describe('experiment archive', () => { reopened.database .prepare('SELECT COUNT(*) AS count FROM schema_migrations') .get(), - ).toEqual({ count: 1 }); + ).toEqual({ count: migrations.length }); reopened.close(); }); diff --git a/packages/experiment-archive/src/importer.ts b/packages/experiment-archive/src/importer.ts index 5d12b5f..3139bf0 100644 --- a/packages/experiment-archive/src/importer.ts +++ b/packages/experiment-archive/src/importer.ts @@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { - AGENT_DECISION_CONTRACT_VERSION, experimentExportDocumentSchema, exportedCommunicationSchema, type ExperimentExportDocument, @@ -185,7 +184,7 @@ export function importExperimentExport( json(document.experiment.modelConfiguration), json(document.experiment.behaviorConfiguration), scenario?.objectiveVersion ?? null, - AGENT_DECISION_CONTRACT_VERSION, + document.experiment.decisionContractVersion, `experiment-export-schema-v${document.schemaVersion}`, document.retention.limit, document.retention.totalCompletedTurns, @@ -207,6 +206,11 @@ export function importExperimentExport( db.prepare( ` UPDATE experiments SET + schema_version = MAX(schema_version, ?), + observation_contract_version = CASE + WHEN schema_version < ? THEN ? + ELSE observation_contract_version + END, scenario_json = COALESCE(scenario_json, ?), model_configuration_json = COALESCE(model_configuration_json, ?), behavior_configuration_json = COALESCE(behavior_configuration_json, ?), @@ -223,6 +227,9 @@ export function importExperimentExport( WHERE id = ? `, ).run( + document.schemaVersion, + document.schemaVersion, + `experiment-export-schema-v${document.schemaVersion}`, json(scenario), json(document.experiment.modelConfiguration), json(document.experiment.behaviorConfiguration), @@ -401,7 +408,8 @@ function importTurns( const movement = movementAttribution(document); const turnStatement = archive.database.prepare(` INSERT OR IGNORE INTO turns( - id, experiment_id, turn_number, agent_id, started_at, completed_at, + id, experiment_id, turn_number, tick_number, tick_position, + virtual_time, tick_interval_minutes, agent_id, started_at, completed_at, outcome, action, action_accepted, action_reason, summary, world_action_summary, communication_summary, diplomacy_summary, position_before, position_after, move_direction, @@ -411,7 +419,7 @@ function importTurns( cache_write_tokens, cost_credits, observation_size_bytes, observation_json, world_action_json, world_action_result_json, communication_result_json, diplomacy_result_json, failure_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const attemptStatement = archive.database.prepare(` INSERT OR IGNORE INTO model_attempts( @@ -446,6 +454,10 @@ function importTurns( turnId, document.experiment.id, turn.turnNumber, + turn.tickNumber ?? null, + turn.tickPosition ?? null, + turn.virtualTime ?? null, + turn.tickIntervalMinutes ?? null, turn.agentId, turn.startedAt, turn.completedAt, diff --git a/packages/experiment-archive/src/migrations.ts b/packages/experiment-archive/src/migrations.ts index ec271ea..976f347 100644 --- a/packages/experiment-archive/src/migrations.ts +++ b/packages/experiment-archive/src/migrations.ts @@ -265,4 +265,15 @@ export const migrations: readonly Migration[] = [ ); `, }, + { + version: 2, + description: 'schema-v10 simultaneous tick attribution', + sql: ` + ALTER TABLE turns ADD COLUMN tick_number INTEGER; + ALTER TABLE turns ADD COLUMN tick_position INTEGER; + ALTER TABLE turns ADD COLUMN virtual_time TEXT; + ALTER TABLE turns ADD COLUMN tick_interval_minutes INTEGER; + CREATE INDEX turns_experiment_tick_idx ON turns(experiment_id, tick_number, tick_position); + `, + }, ] as const; diff --git a/packages/experiment-archive/src/query-service.ts b/packages/experiment-archive/src/query-service.ts index 29526c3..750385c 100644 --- a/packages/experiment-archive/src/query-service.ts +++ b/packages/experiment-archive/src/query-service.ts @@ -118,7 +118,10 @@ export class ExperimentQueryService { const rows = this.#db .prepare( ` - SELECT turn_number AS turn, agent_id AS agent, outcome, action, + SELECT turn_number AS turn, tick_number AS tick, + tick_position AS tickPosition, virtual_time AS virtualTime, + tick_interval_minutes AS tickIntervalMinutes, + agent_id AS agent, outcome, action, action_accepted AS actionAccepted, action_reason AS reason, position_before AS positionBefore, position_after AS positionAfter, model_id AS model, reasoning_profile AS reasoning, @@ -458,7 +461,7 @@ export class ExperimentQueryService { SUM(outcome = 'accepted') AS accepted, SUM(outcome = 'rejected') AS rejected, SUM(outcome = 'provider-error') AS failed, - SUM(outcome = 'operator-skipped') AS lost, + SUM(outcome IN ('operator-skipped', 'lost-tick')) AS lost, SUM((SELECT COUNT(*) FROM model_attempts m WHERE m.turn_id = turns.id) > 1) AS retried FROM turns WHERE experiment_id = ? `, @@ -472,6 +475,34 @@ export class ExperimentQueryService { `, ) .all(experimentId); + const ticks = this.#db + .prepare( + ` + WITH attempt_totals AS ( + SELECT turn_id, COUNT(*) AS providerCalls, + SUM(latency_ms) AS aggregateLatencyMs, + MAX(latency_ms) AS maximumLatencyMs, + SUM(cost_credits) AS knownCostCredits, + SUM(cost_credits IS NULL) AS attemptsWithUnknownCost + FROM model_attempts WHERE experiment_id = ? GROUP BY turn_id + ) + SELECT tick_number AS tick, MIN(virtual_time) AS virtualTime, + MIN(tick_interval_minutes) AS intervalMinutes, + COUNT(*) AS agentRecords, + SUM(outcome = 'lost-tick') AS lostTicks, + SUM(outcome = 'lost-tick' AND json_extract(failure_json, '$.code') = 'timeout') AS deadlineMisses, + SUM(COALESCE(a.providerCalls, 0)) AS providerCallCount, + ROUND(SUM(COALESCE(a.knownCostCredits, 0)), 8) AS knownCostCredits, + SUM(COALESCE(a.attemptsWithUnknownCost, 0)) AS attemptsWithUnknownCost, + SUM(COALESCE(a.aggregateLatencyMs, 0)) AS aggregateLatencyMs, + MAX(COALESCE(a.maximumLatencyMs, 0)) AS maximumLatencyMs + FROM turns t LEFT JOIN attempt_totals a ON a.turn_id = t.id + WHERE t.experiment_id = ? AND tick_number IS NOT NULL + GROUP BY tick_number ORDER BY tick_number ASC + LIMIT ? + `, + ) + .all(experimentId, experimentId, MAX_DETAIL_LIMIT); const communications = this.#db .prepare( ` @@ -598,6 +629,7 @@ export class ExperimentQueryService { roster, sourceExports, turns: turnMetrics, + ticks, actions, territory: { current: territoryRows.length > 0 ? territoryRows : sourceTerritory, @@ -839,7 +871,7 @@ function comparisonMetrics(db: DatabaseSync, experimentId: string) { COUNT(DISTINCT agent_id) AS activeAgents, SUM(outcome = 'accepted') AS accepted, SUM(outcome = 'provider-error') AS failed, - SUM(outcome = 'operator-skipped') AS lost + SUM(outcome IN ('operator-skipped', 'lost-tick')) AS lost FROM turns WHERE experiment_id = ? `, ) diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 7e53d8c..f47301d 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -14,6 +14,7 @@ import { captureEligibilitySchema, exportedCommunicationSchema, experimentExportWorldStateSchema, + experimentExportTurnSchema, hexCapturedWorldEventSchema, hexSchema, invalidActionReasonSchema, @@ -23,6 +24,7 @@ import { providerMetadataSchema, restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, + singleTickResponseSchema, updateAgentPersonalityRequestSchema, updateAgentPersonalityResponseSchema, allianceSchema, @@ -31,6 +33,7 @@ import { diplomacyResultSchema, DEVELOPMENT_WORLD_CONFIG, experimentModelConfigurationSchema, + modelVerificationSchema, reasoningProfilesForModel, type CompatibleModel, assignBehavior, @@ -245,8 +248,22 @@ const snapshot = { describe('agent observation and decision schemas', () => { it('preserves established engine contract identifiers through branding changes', () => { - expect(AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v3'); + expect(AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v4'); expect(OBJECTIVE_PROMPT_VERSION).toBe('durable-influence-v2'); + expect( + modelVerificationSchema.parse({ + modelId: 'author/model', + contractVersion: AGENT_DECISION_CONTRACT_VERSION, + status: 'untested', + }).contractVersion, + ).toBe(AGENT_DECISION_CONTRACT_VERSION); + expect( + modelVerificationSchema.safeParse({ + modelId: 'author/model', + contractVersion: 'text-flat-json-v3', + status: 'untested', + }).success, + ).toBe(false); }); it('centralizes eight-agent, 127-cell alliance and diplomacy limits', () => { @@ -683,6 +700,15 @@ describe('turn and snapshot schemas', () => { outcome: 'provider-error', failure: { code: 'timeout', message: 'Timed out.', retryable: true }, }, + { + ...baseTurn, + outcome: 'lost-tick', + tickNumber: 1, + tickPosition: 1, + virtualTime: '2026-08-13T12:05:00.000Z', + tickIntervalMinutes: 5, + failure: { code: 'timeout', message: 'Timed out.', retryable: false }, + }, ])('validates $outcome turn records', (turn) => { expect(agentTurnRecordSchema.safeParse(turn).success).toBe(true); }); @@ -715,6 +741,83 @@ describe('turn and snapshot schemas', () => { }).success, ).toBe(false); }); + + it('requires tick attribution as one complete metadata group', () => { + const lost = { + ...baseTurn, + outcome: 'lost-tick', + failure: { code: 'timeout', message: 'Timed out.', retryable: false }, + }; + expect( + agentTurnRecordSchema.safeParse({ ...lost, tickNumber: 1 }).success, + ).toBe(false); + expect( + agentTurnRecordSchema.safeParse({ + ...lost, + tickNumber: 1, + tickPosition: 1, + virtualTime: '2026-08-13T12:05:00.000Z', + tickIntervalMinutes: 5, + }).success, + ).toBe(true); + expect( + experimentExportTurnSchema.safeParse({ + turnNumber: 1, + tickNumber: 1, + startedAt: baseTurn.startedAt, + completedAt: baseTurn.completedAt, + agentId, + outcome: 'lost-tick', + failure: { code: 'timeout', message: 'Timed out.', retryable: false }, + }).success, + ).toBe(false); + }); + + it('requires one consistently attributed record per roster agent in tick responses', () => { + const virtualTime = '2026-08-13T12:05:00.000Z'; + const records = worldAgents.map((agent, index) => ({ + ...baseTurn, + turnNumber: index + 1, + agentId: agent.id, + observation: { ...observation, agentId: agent.id, agentName: agent.name }, + outcome: 'lost-tick', + tickNumber: 1, + tickPosition: index + 1, + virtualTime, + tickIntervalMinutes: 5, + failure: { code: 'timeout', message: 'Timed out.', retryable: false }, + })); + const tickSnapshot = { + ...snapshot, + turnNumber: records.length, + tickNumber: 1, + virtualTime, + lastTickIntervalMinutes: 5, + resolutionOrder: records.map(({ agentId: id }) => id), + turns: records, + }; + expect( + singleTickResponseSchema.safeParse({ + snapshot: tickSnapshot, + tickNumber: 1, + records, + }).success, + ).toBe(true); + expect( + singleTickResponseSchema.safeParse({ + snapshot: tickSnapshot, + tickNumber: 1, + records: records.slice(1), + }).success, + ).toBe(false); + expect( + singleTickResponseSchema.safeParse({ + snapshot: tickSnapshot, + tickNumber: 1, + records: records.map((record) => ({ ...record, tickPosition: 1 })), + }).success, + ).toBe(false); + }); }); describe('experiment telemetry and export contracts', () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 69b78af..d9cca6a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,8 @@ export const MESSAGE_MAX_LENGTH = 280; /** Legacy H3-ring range retained only for schema-v5-v9 import compatibility. */ export const MESSAGE_RANGE = 3; export const DEFAULT_COMMUNICATION_RANGE_KM = 12; +export const DEFAULT_MINIMUM_TICK_INTERVAL_MINUTES = 5; +export const DEFAULT_MAXIMUM_TICK_INTERVAL_MINUTES = 10; export const NEUTRAL_AGENT_COLOR = '#b2d3a8'; export const RECENT_PUBLIC_MESSAGE_LIMIT = 12; export const RECENT_DIRECT_MESSAGE_LIMIT = 6; @@ -28,7 +30,12 @@ export const OPENROUTER_MODEL_CONTEXT_MINIMUM = 16_384; export const OPENROUTER_MAX_OUTPUT_TOKENS = 4_096; export const OPENROUTER_PROVIDER_TIMEOUT_MS = 75_000; export const OPENROUTER_429_FALLBACK_BACKOFF_MS = 1_500; -export const AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v3'; +export const LEGACY_AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v3'; +export const AGENT_DECISION_CONTRACT_VERSION = 'text-flat-json-v4'; +export const agentDecisionContractVersionSchema = z.enum([ + LEGACY_AGENT_DECISION_CONTRACT_VERSION, + AGENT_DECISION_CONTRACT_VERSION, +]); export const OBJECTIVE_PROMPT_VERSION = 'durable-influence-v2'; export const OPENROUTER_REQUIRED_PARAMETERS = ['max_tokens'] as const; export const DEVELOPMENT_WORLD_CONFIG = { @@ -180,6 +187,8 @@ export const allianceProposalSchema = z proposerAllianceId: allianceIdSchema.nullable(), originatingTurn: z.number().int().positive(), expirationTurn: z.number().int().positive(), + originatingTick: z.number().int().positive().optional(), + expirationTick: z.number().int().positive().optional(), }) .strict() .refine( @@ -187,7 +196,25 @@ export const allianceProposalSchema = z { message: 'Alliance proposal participants must be distinct.', }, - ); + ) + .superRefine((proposal, context) => { + const hasOriginatingTick = proposal.originatingTick !== undefined; + const hasExpirationTick = proposal.expirationTick !== undefined; + if (hasOriginatingTick !== hasExpirationTick) + context.addIssue({ + code: 'custom', + message: 'Tick proposal attribution must be present together.', + }); + else if ( + hasOriginatingTick && + proposal.expirationTick !== proposal.originatingTick! + 2 + ) + context.addIssue({ + code: 'custom', + path: ['expirationTick'], + message: 'Tick proposals must remain valid for exactly two ticks.', + }); + }); export type AllianceProposal = z.infer; export const waitActionSchema = z.object({ type: z.literal('wait') }); @@ -606,14 +633,20 @@ function validateWorldControllers( path: ['pendingAllianceProposals', index, 'recipientAgentId'], message: 'A recipient may have at most one incoming proposal.', }); - if ( - proposal.expirationTurn !== - proposal.originatingTurn + world.agents.length * 2 - ) + const hasOriginatingTick = proposal.originatingTick !== undefined; + const hasExpirationTick = proposal.expirationTick !== undefined; + const validLifetime = + hasOriginatingTick === hasExpirationTick && + (hasOriginatingTick + ? proposal.expirationTick === proposal.originatingTick! + 2 + : proposal.expirationTurn === + proposal.originatingTurn + world.agents.length * 2); + if (!validLifetime) context.addIssue({ code: 'custom', path: ['pendingAllianceProposals', index, 'expirationTurn'], - message: 'Proposal expiration must allow two complete roster rounds.', + message: + 'Proposal expiration must allow two ticks or two legacy roster rounds.', }); if ( proposal.proposerAllianceId && @@ -1426,6 +1459,10 @@ export type ModelAttempt = z.infer; const turnRecordBaseSchema = z.object({ turnNumber: z.number().int().positive(), + tickNumber: z.number().int().positive().optional(), + tickPosition: z.number().int().positive().optional(), + virtualTime: z.iso.datetime().optional(), + tickIntervalMinutes: z.number().int().positive().optional(), agentId: agentIdSchema, startedAt: z.iso.datetime(), completedAt: z.iso.datetime(), @@ -1469,6 +1506,11 @@ export const agentTurnRecordSchema = z failure: providerFailureSchema, provider: providerMetadataSchema.optional(), }), + turnRecordBaseSchema.extend({ + outcome: z.literal('lost-tick'), + failure: providerFailureSchema, + provider: providerMetadataSchema.optional(), + }), turnRecordBaseSchema.extend({ outcome: z.literal('operator-skipped'), skipKind: z.enum(['manual', 'unattended']).default('manual'), @@ -1477,6 +1519,20 @@ export const agentTurnRecordSchema = z }), ]) .superRefine((turn, context) => { + const tickFields = [ + turn.tickNumber, + turn.tickPosition, + turn.virtualTime, + turn.tickIntervalMinutes, + ]; + if ( + tickFields.some((value) => value !== undefined) && + tickFields.some((value) => value === undefined) + ) + context.addIssue({ + code: 'custom', + message: 'Tick metadata must be present together.', + }); if ( turn.outcome === 'operator-skipped' && turn.failure.model !== undefined && @@ -1574,6 +1630,18 @@ export const worldSetupRequestSchema = z .min(WORLD_SCENARIO_LIMITS.minimumCommunicationRangeKm) .max(WORLD_SCENARIO_LIMITS.maximumCommunicationRangeKm) .default(DEFAULT_COMMUNICATION_RANGE_KM), + minimumTickIntervalMinutes: z + .number() + .int() + .min(WORLD_SCENARIO_LIMITS.minimumTickIntervalMinutes) + .max(WORLD_SCENARIO_LIMITS.maximumTickIntervalMinutes) + .default(DEFAULT_MINIMUM_TICK_INTERVAL_MINUTES), + maximumTickIntervalMinutes: z + .number() + .int() + .min(WORLD_SCENARIO_LIMITS.minimumTickIntervalMinutes) + .max(WORLD_SCENARIO_LIMITS.maximumTickIntervalMinutes) + .default(DEFAULT_MAXIMUM_TICK_INTERVAL_MINUTES), patientZeroAgentId: agentIdSchema.nullable().default(null), roster: scenarioRosterSchema, modelConfiguration: experimentModelConfigurationSchema, @@ -1587,6 +1655,12 @@ export const worldSetupRequestSchema = z }) .strict() .superRefine((request, context) => { + if (request.minimumTickIntervalMinutes > request.maximumTickIntervalMinutes) + context.addIssue({ + code: 'custom', + path: ['maximumTickIntervalMinutes'], + message: 'Maximum tick interval must be at least the minimum.', + }); const ids = new Set(request.roster.map(({ id }) => id)); if ( request.behaviorConfiguration.assignments.length !== ids.size || @@ -1626,6 +1700,9 @@ export const defaultWorldSetupResponseSchema = z.object({ export const appliedScenarioSchema = worldSetupRequestSchema .safeExtend({ + decisionContractVersion: agentDecisionContractVersionSchema.default( + AGENT_DECISION_CONTRACT_VERSION, + ), exactCellCount: z .number() .int() @@ -1713,6 +1790,15 @@ export const simulationSnapshotSchema = z world: worldSnapshotSchema, scenario: appliedScenarioSchema, turnNumber: z.number().int().nonnegative(), + tickNumber: z.number().int().nonnegative().default(0), + virtualTime: z.iso.datetime().default('2026-08-13T12:00:00.000Z'), + lastTickIntervalMinutes: z + .number() + .int() + .positive() + .nullable() + .default(null), + resolutionOrder: z.array(agentIdSchema).default([]), nextAgentId: agentIdSchema, activeAgentId: agentIdSchema.nullable(), cancellationRequested: z.boolean().default(false), @@ -1751,6 +1837,29 @@ export const simulationSnapshotSchema = z }) .superRefine((snapshot, context) => { const rosterIds = new Set(snapshot.world.agents.map(({ id }) => id)); + if (snapshot.tickNumber === 0) { + if ( + snapshot.resolutionOrder.length !== 0 || + snapshot.lastTickIntervalMinutes !== null + ) + context.addIssue({ + code: 'custom', + path: ['tickNumber'], + message: + 'An unstarted simulation cannot have tick resolution metadata.', + }); + } else if ( + snapshot.resolutionOrder.length !== rosterIds.size || + new Set(snapshot.resolutionOrder).size !== rosterIds.size || + snapshot.resolutionOrder.some((id) => !rosterIds.has(id)) || + snapshot.lastTickIntervalMinutes === null + ) + context.addIssue({ + code: 'custom', + path: ['resolutionOrder'], + message: + 'A committed tick requires one ordered entry per active agent and an interval.', + }); if ( snapshot.experiment.currentTerritory.length !== rosterIds.size || snapshot.experiment.currentTerritory.some( @@ -1900,6 +2009,43 @@ export const singleTurnResponseSchema = z.object({ }); export type SingleTurnResponse = z.infer; +export const singleTickResponseSchema = z + .object({ + snapshot: simulationSnapshotSchema, + tickNumber: z.number().int().positive(), + records: z.array(agentTurnRecordSchema).min(1), + }) + .superRefine((response, context) => { + const roster = response.snapshot.world.agents.map(({ id }) => id); + const positions = response.records.map(({ tickPosition }) => tickPosition); + const agents = response.records.map(({ agentId }) => agentId); + const first = response.records[0]; + const consistent = + response.snapshot.tickNumber === response.tickNumber && + response.records.length === roster.length && + new Set(agents).size === roster.length && + agents.every((id) => roster.includes(id)) && + positions.every((position, index) => position === index + 1) && + response.records.every( + (record) => + record.tickNumber === response.tickNumber && + record.virtualTime === first?.virtualTime && + record.tickIntervalMinutes === first?.tickIntervalMinutes, + ) && + agents.every( + (id, index) => response.snapshot.resolutionOrder[index] === id, + ) && + response.snapshot.virtualTime === first?.virtualTime && + response.snapshot.lastTickIntervalMinutes === first?.tickIntervalMinutes; + if (!consistent) + context.addIssue({ + code: 'custom', + message: + 'Tick response records must exactly match the committed snapshot and roster.', + }); + }); +export type SingleTickResponse = z.infer; + export const cancelledTurnResponseSchema = z.object({ snapshot: simulationSnapshotSchema, cancelled: z.literal(true), @@ -2029,20 +2175,63 @@ export type ExperimentConfigurationEvent = z.infer< typeof experimentConfigurationEventSchema >; -export const experimentManifestSchema = z.object({ - id: experimentIdSchema, - startedAt: z.iso.datetime(), - generatedAt: z.iso.datetime().optional(), - providerMode: providerModeSchema, - modelConfiguration: experimentModelConfigurationSchema.optional(), - behaviorConfiguration: behaviorConfigurationSchema.optional(), - scenario: appliedScenarioSchema.optional(), - initialAgents: z - .array(agentProfileSchema) - .min(WORLD_SCENARIO_LIMITS.minimumAgents) - .max(WORLD_SCENARIO_LIMITS.maximumAgents) - .optional(), -}); +export const experimentManifestSchema = z.preprocess( + (input) => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + return input; + const manifest = input as Record; + const resolvedVersion = + manifest.decisionContractVersion === AGENT_DECISION_CONTRACT_VERSION + ? AGENT_DECISION_CONTRACT_VERSION + : LEGACY_AGENT_DECISION_CONTRACT_VERSION; + const scenario = manifest.scenario; + return { + ...manifest, + decisionContractVersion: + manifest.decisionContractVersion ?? resolvedVersion, + ...(typeof scenario === 'object' && + scenario !== null && + !Array.isArray(scenario) && + !('decisionContractVersion' in scenario) + ? { + scenario: { + ...scenario, + decisionContractVersion: resolvedVersion, + }, + } + : {}), + }; + }, + z + .object({ + id: experimentIdSchema, + startedAt: z.iso.datetime(), + generatedAt: z.iso.datetime().optional(), + providerMode: providerModeSchema, + decisionContractVersion: agentDecisionContractVersionSchema, + modelConfiguration: experimentModelConfigurationSchema.optional(), + behaviorConfiguration: behaviorConfigurationSchema.optional(), + scenario: appliedScenarioSchema.optional(), + initialAgents: z + .array(agentProfileSchema) + .min(WORLD_SCENARIO_LIMITS.minimumAgents) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), + }) + .superRefine((manifest, context) => { + if ( + manifest.scenario !== undefined && + manifest.scenario.decisionContractVersion !== + manifest.decisionContractVersion + ) + context.addIssue({ + code: 'custom', + path: ['scenario', 'decisionContractVersion'], + message: + 'Scenario decision-contract attribution must match the experiment manifest.', + }); + }), +); export type ExperimentManifest = z.infer; export const experimentRetentionSchema = z.object({ @@ -2072,6 +2261,7 @@ export const metricCountsSchema = z accepted: z.number().int().nonnegative(), rejected: z.number().int().nonnegative(), providerErrors: z.number().int().nonnegative(), + lostTicks: z.number().int().nonnegative().default(0), operatorSkipped: z.number().int().nonnegative().default(0), modelCalls: z.number().int().nonnegative().default(0), failedModelAttempts: z.number().int().nonnegative().default(0), @@ -2215,6 +2405,7 @@ export const metricCountsSchema = z metrics.accepted + metrics.rejected + metrics.providerErrors + + metrics.lostTicks + metrics.operatorSkipped ) context.addIssue({ @@ -2263,6 +2454,7 @@ export const exportOutcomeSchema = z.enum([ 'rejected', 'provider-error', 'operator-skipped', + 'lost-tick', ]); export const exportActionSchema = z.enum(['move', 'infect', 'capture', 'wait']); export const exportCommunicationChannelSchema = z.enum([ @@ -2367,7 +2559,7 @@ export const experimentExportRequestSchema = z .object({ agents: exportSelectionSchema, turns: exportTurnSelectionSchema, - outcomes: z.array(exportOutcomeSchema).min(1).max(4), + outcomes: z.array(exportOutcomeSchema).min(1).max(5), actions: z.array(exportActionSchema).min(1).max(4), communications: z .object({ @@ -2404,6 +2596,7 @@ export type ExperimentExportRequest = z.infer< export const experimentExportPreviewSchema = z.object({ experimentId: experimentIdSchema, matchingTurnCount: z.number().int().nonnegative(), + matchingTickCount: z.number().int().nonnegative().optional(), matchingCommunicationCount: z.number().int().nonnegative(), matchingControlChangeCount: z.number().int().nonnegative(), matchingDiplomacyEventCount: z.number().int().nonnegative(), @@ -2426,29 +2619,65 @@ export type ExperimentExportPreview = z.infer< typeof experimentExportPreviewSchema >; -export const experimentExportTurnSchema = z.object({ - turnNumber: z.number().int().positive(), - startedAt: z.iso.datetime(), - completedAt: z.iso.datetime(), - agentId: agentIdSchema, - behavior: behaviorAssignmentSchema.optional(), - outcome: exportOutcomeSchema, - worldAction: worldActionSchema.optional(), - communication: communicationIntentSchema.optional(), - diplomacy: diplomacyIntentSchema.optional(), - summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH).optional(), - worldActionSummary: z.string().trim().min(1).max(300).optional(), - communicationSummary: z.string().trim().min(1).max(300).optional(), - diplomacySummary: z.string().trim().min(1).max(300).optional(), - personality: personalitySchema.optional(), - observation: agentObservationObjectSchema.partial().optional(), - worldActionResult: worldActionResultSchema.optional(), - communicationResult: communicationResultSchema.optional(), - diplomacyResult: diplomacyResultSchema.optional(), - failure: providerFailureSchema.optional(), - provider: providerMetadataSchema.optional(), - modelAttempts: z.array(modelAttemptSchema).max(1_000).default([]), -}); +export const experimentTickSummarySchema = z.object({ + tickNumber: z.number().int().positive(), + virtualTime: z.iso.datetime(), + intervalMinutes: z.number().int().positive(), + agentRecordCount: z.number().int().positive(), + lostTicks: z.number().int().nonnegative(), + deadlineMisses: z.number().int().nonnegative(), + providerCallCount: z.number().int().nonnegative(), + aggregateDecisionLatencyMs: z.number().int().nonnegative(), + maximumDecisionLatencyMs: z.number().int().nonnegative(), + knownCostCredits: z.number().finite().nonnegative(), + attemptsWithUnknownCost: z.number().int().nonnegative(), +}); +export type ExperimentTickSummary = z.infer; + +export const experimentExportTurnSchema = z + .object({ + turnNumber: z.number().int().positive(), + tickNumber: z.number().int().positive().optional(), + tickPosition: z.number().int().positive().optional(), + virtualTime: z.iso.datetime().optional(), + tickIntervalMinutes: z.number().int().positive().optional(), + startedAt: z.iso.datetime(), + completedAt: z.iso.datetime(), + agentId: agentIdSchema, + behavior: behaviorAssignmentSchema.optional(), + outcome: exportOutcomeSchema, + worldAction: worldActionSchema.optional(), + communication: communicationIntentSchema.optional(), + diplomacy: diplomacyIntentSchema.optional(), + summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH).optional(), + worldActionSummary: z.string().trim().min(1).max(300).optional(), + communicationSummary: z.string().trim().min(1).max(300).optional(), + diplomacySummary: z.string().trim().min(1).max(300).optional(), + personality: personalitySchema.optional(), + observation: agentObservationObjectSchema.partial().optional(), + worldActionResult: worldActionResultSchema.optional(), + communicationResult: communicationResultSchema.optional(), + diplomacyResult: diplomacyResultSchema.optional(), + failure: providerFailureSchema.optional(), + provider: providerMetadataSchema.optional(), + modelAttempts: z.array(modelAttemptSchema).max(1_000).default([]), + }) + .superRefine((turn, context) => { + const fields = [ + turn.tickNumber, + turn.tickPosition, + turn.virtualTime, + turn.tickIntervalMinutes, + ]; + if ( + fields.some((value) => value !== undefined) && + fields.some((value) => value === undefined) + ) + context.addIssue({ + code: 'custom', + message: 'Export tick metadata must be present together.', + }); + }); export const experimentExportWorldStateSchema = worldSnapshotObjectSchema .omit({ events: true }) @@ -2536,7 +2765,7 @@ export type ExperimentExportWorldState = z.infer< export const experimentExportDocumentSchema = z .object({ - schemaVersion: z.literal(9), + schemaVersion: z.union([z.literal(9), z.literal(10)]), generatedAt: z.iso.datetime(), experiment: experimentManifestSchema, retention: experimentRetentionSchema, @@ -2547,6 +2776,7 @@ export const experimentExportDocumentSchema = z .min(1) .max(WORLD_SCENARIO_LIMITS.maximumAgents), matchingTurnCount: z.number().int().nonnegative(), + matchingTickCount: z.number().int().nonnegative().optional(), matchingCommunicationCount: z.number().int().nonnegative(), matchingControlChangeCount: z.number().int().nonnegative(), matchingDiplomacyEventCount: z.number().int().nonnegative(), @@ -2571,9 +2801,93 @@ export const experimentExportDocumentSchema = z communications: z.array(exportedCommunicationSchema).optional(), controlChanges: z.array(exportedControlChangeSchema).optional(), allianceEvents: z.array(allianceEventSchema).optional(), + tickSummaries: z.array(experimentTickSummarySchema).optional(), turns: z.array(experimentExportTurnSchema), }) .superRefine((document, context) => { + const hasTickMetadata = (turn: (typeof document.turns)[number]) => + turn.tickNumber !== undefined && + turn.tickPosition !== undefined && + turn.virtualTime !== undefined && + turn.tickIntervalMinutes !== undefined; + if (document.schemaVersion === 9 && document.turns.some(hasTickMetadata)) + context.addIssue({ + code: 'custom', + message: 'Schema-v9 turns cannot claim simultaneous tick metadata.', + }); + if (document.schemaVersion === 10) { + if (document.tickSummaries === undefined) + context.addIssue({ + code: 'custom', + message: 'Schema-v10 exports require canonical tick summaries.', + }); + if (document.selection.matchingTickCount === undefined) + context.addIssue({ + code: 'custom', + message: 'Schema-v10 exports require a matching tick count.', + }); + if (document.turns.some((turn) => !hasTickMetadata(turn))) + context.addIssue({ + code: 'custom', + message: 'Every schema-v10 turn requires complete tick metadata.', + }); + const groups = new Map(); + for (const turn of document.turns) { + if (turn.tickNumber === undefined) continue; + groups.set(turn.tickNumber, [ + ...(groups.get(turn.tickNumber) ?? []), + turn, + ]); + } + for (const [tick, records] of groups) { + const first = records[0]; + const summary = document.tickSummaries?.find( + ({ tickNumber }) => tickNumber === tick, + ); + if ( + new Set(records.map(({ agentId }) => agentId)).size !== + records.length || + new Set(records.map(({ tickPosition }) => tickPosition)).size !== + records.length || + records.some( + (record) => + record.virtualTime !== first?.virtualTime || + record.tickIntervalMinutes !== first?.tickIntervalMinutes, + ) + ) + context.addIssue({ + code: 'custom', + message: `Schema-v10 tick ${tick} is internally inconsistent.`, + }); + if ( + !summary || + summary.agentRecordCount !== records.length || + summary.virtualTime !== first?.virtualTime || + summary.intervalMinutes !== first?.tickIntervalMinutes + ) + context.addIssue({ + code: 'custom', + message: `Schema-v10 tick ${tick} summary does not match its records.`, + }); + } + if ( + document.tickSummaries !== undefined && + (new Set(document.tickSummaries.map(({ tickNumber }) => tickNumber)) + .size !== document.tickSummaries.length || + document.tickSummaries.length !== groups.size || + document.selection.matchingTickCount !== groups.size) + ) + context.addIssue({ + code: 'custom', + message: + 'Schema-v10 tick counts and summaries must match exported records.', + }); + } + if (document.schemaVersion === 9 && document.tickSummaries !== undefined) + context.addIssue({ + code: 'custom', + message: 'Schema-v9 exports cannot contain tick summaries.', + }); const level = document.filters.level; const custom = level === 'custom' ? document.filters.custom : undefined; const requiresMetrics = level !== 'custom' || custom?.computedMetrics; diff --git a/packages/shared/src/limits.ts b/packages/shared/src/limits.ts index 340a332..60fa8ce 100644 --- a/packages/shared/src/limits.ts +++ b/packages/shared/src/limits.ts @@ -11,6 +11,8 @@ export const WORLD_SCENARIO_LIMITS = { maximumNearbyAgentObservations: 8, minimumCommunicationRangeKm: 0.1, maximumCommunicationRangeKm: 100, + minimumTickIntervalMinutes: 1, + maximumTickIntervalMinutes: 60, } as const; export const WORLD_RADIUS_PRESETS = { diff --git a/packages/shared/src/scenario.test.ts b/packages/shared/src/scenario.test.ts index b4819c3..97b56d2 100644 --- a/packages/shared/src/scenario.test.ts +++ b/packages/shared/src/scenario.test.ts @@ -6,6 +6,8 @@ import { worldSetupPreviewResponseSchema, worldSetupRequestSchema, WORLD_SCENARIO_LIMITS, + AGENT_DECISION_CONTRACT_VERSION, + LEGACY_AGENT_DECISION_CONTRACT_VERSION, } from './index'; const roster = [ @@ -49,6 +51,19 @@ const request = { }; describe('scenario contracts', () => { + it('defaults safe virtual tick bounds and rejects inverted bounds', () => { + const parsed = worldSetupRequestSchema.parse(request); + expect(parsed.minimumTickIntervalMinutes).toBe(5); + expect(parsed.maximumTickIntervalMinutes).toBe(10); + expect( + worldSetupRequestSchema.safeParse({ + ...parsed, + minimumTickIntervalMinutes: 11, + maximumTickIntervalMinutes: 10, + }).success, + ).toBe(false); + }); + it('centralizes temporary limits', () => { expect(WORLD_SCENARIO_LIMITS).toMatchObject({ minimumAgents: 1, @@ -127,4 +142,22 @@ describe('scenario contracts', () => { }).success, ).toBe(false); }); + + it('keeps prompt attribution out of setup input and attributes applied scenarios to v4', () => { + expect( + worldSetupRequestSchema.safeParse({ + ...request, + decisionContractVersion: LEGACY_AGENT_DECISION_CONTRACT_VERSION, + }).success, + ).toBe(false); + expect( + appliedScenarioSchema.parse({ + ...worldSetupRequestSchema.parse(request), + exactCellCount: 1, + areaSquareKilometers: 0.1, + startingCells: ['8928308280fffff'], + setupWarnings: [], + }).decisionContractVersion, + ).toBe(AGENT_DECISION_CONTRACT_VERSION); + }); }); diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index 197e7b0..c154405 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -9,6 +9,8 @@ import { createDevelopmentWorld, getCaptureEligibility, expireAllianceProposals, + seededTickIntervalMinutes, + seededTickOrder, toWorldState, } from '.'; @@ -36,6 +38,19 @@ const context = { now: () => '2026-08-13T12:00:00.000Z', }; +describe('simultaneous tick determinism', () => { + it('reproduces a shuffled resolution order and inclusive virtual interval', () => { + const ids = [agentId, recipientId, thirdAgentId]; + expect(seededTickOrder(ids, 'scenario-a', 7)).toEqual( + seededTickOrder(ids, 'scenario-a', 7), + ); + const interval = seededTickIntervalMinutes('scenario-a', 7, 5, 10); + expect(interval).toBeGreaterThanOrEqual(5); + expect(interval).toBeLessThanOrEqual(10); + expect(interval).toBe(seededTickIntervalMinutes('scenario-a', 7, 5, 10)); + }); +}); + function stateWithAgent() { const base = toWorldState( createDevelopmentWorld({ generatedAt: '2026-08-13T12:00:00.000Z' }), @@ -589,7 +604,7 @@ describe('formal alliances', () => { { agentCount: 8, lifetime: 16 }, { agentCount: 20, lifetime: 40 }, ])( - 'gives proposals a $lifetime-turn lifetime for an $agentCount-agent roster', + 'preserves a $lifetime-turn legacy lifetime for an $agentCount-agent roster', ({ agentCount, lifetime }) => { const base = toWorldState( createDevelopmentWorld({ generatedAt: context.now() }), @@ -618,11 +633,12 @@ describe('formal alliances', () => { ...proposed.state.pendingAllianceProposals!.values(), ][0]!; - expect(proposal.expirationTurn).toBe(1 + lifetime); + expect(proposal).toMatchObject({ expirationTurn: 1 + lifetime }); + expect(proposal.originatingTick).toBeUndefined(); }, ); - it('keeps a proposal open for two scheduled recipient opportunities', () => { + it('uses explicit two-tick expiry while retaining record ordinals', () => { const initial = toWorldState( createDevelopmentWorld({ generatedAt: context.now() }), ); @@ -632,26 +648,26 @@ describe('formal alliances', () => { proposer!.id, { type: 'propose-alliance', recipientId: recipient!.id }, 1, - context, - ); - - const afterFirstOpportunity = expireAllianceProposals( - proposed.state, - 2, - context, + { ...context, tickNumber: 1 }, ); - const afterSecondOpportunity = expireAllianceProposals( - afterFirstOpportunity, - 10, - context, - ); - expect(afterSecondOpportunity.pendingAllianceProposals?.size).toBe(1); + expect( + [...proposed.state.pendingAllianceProposals!.values()][0], + ).toMatchObject({ + originatingTurn: 1, + expirationTurn: 17, + originatingTick: 1, + expirationTick: 3, + }); - const expired = expireAllianceProposals( - afterSecondOpportunity, - 17, - context, - ); + const afterFirstOpportunity = expireAllianceProposals(proposed.state, 2, { + ...context, + tickNumber: 2, + }); + expect(afterFirstOpportunity.pendingAllianceProposals?.size).toBe(1); + const expired = expireAllianceProposals(afterFirstOpportunity, 17, { + ...context, + tickNumber: 3, + }); expect(expired.pendingAllianceProposals?.size).toBe(0); }); diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index 2547d39..6075e63 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -9,8 +9,11 @@ import { } from 'h3-js'; import { ALLIANCE_COLOR_PALETTE, + AGENT_DECISION_CONTRACT_VERSION, DEVELOPMENT_WORLD_CONFIG, DEFAULT_COMMUNICATION_RANGE_KM, + DEFAULT_MINIMUM_TICK_INTERVAL_MINUTES, + DEFAULT_MAXIMUM_TICK_INTERVAL_MINUTES, NEUTRAL_AGENT_COLOR, assignBehavior, OBJECTIVE_PROMPT_VERSION, @@ -67,6 +70,7 @@ export interface EngineContext { now: () => string; communicationRangeKm: number; patientZeroAgentId: AgentId | null; + tickNumber?: number; } export interface AppliedAction { @@ -542,6 +546,12 @@ export function applyDiplomacy( proposerAllianceId: proposerAlliance?.id ?? null, originatingTurn: turnNumber, expirationTurn: turnNumber + state.agents.size * 2, + ...(context.tickNumber === undefined + ? {} + : { + originatingTick: context.tickNumber, + expirationTick: context.tickNumber + 2, + }), }; proposals.set(proposal.id, proposal); const event: AllianceEvent = { @@ -722,13 +732,19 @@ export function expireAllianceProposals( const proposals = new Map(state.pendingAllianceProposals ?? []); const resolved = { ...defaultContext, ...context }; const events: AllianceEvent[] = []; - for (const proposal of proposals.values()) - if (proposal.expirationTurn <= completedTurn) { + for (const proposal of proposals.values()) { + const expired = + proposal.expirationTick === undefined + ? proposal.expirationTurn <= completedTurn + : context.tickNumber !== undefined && + proposal.expirationTick <= context.tickNumber; + if (expired) { proposals.delete(proposal.id); events.push( proposalClosedEvent(proposal, 'expired', completedTurn, resolved), ); } + } return events.length ? { ...state, @@ -1016,6 +1032,38 @@ function seededNumber(seed: string): () => number { }; } +/** Stable per-tick shuffle used by simulation resolution, never provider completion order. */ +export function seededTickOrder( + values: readonly T[], + scenarioSeed: string, + tickNumber: number, +): T[] { + const random = seededNumber(`${scenarioSeed}:tick-order:${tickNumber}`); + return values + .map((value) => ({ value, rank: random() })) + .sort( + (left, right) => + left.rank - right.rank || left.value.localeCompare(right.value), + ) + .map(({ value }) => value); +} + +export function seededTickIntervalMinutes( + scenarioSeed: string, + tickNumber: number, + minimum: number, + maximum: number, +): number { + if ( + !Number.isInteger(minimum) || + !Number.isInteger(maximum) || + minimum > maximum + ) + throw new Error('Invalid tick interval bounds.'); + const random = seededNumber(`${scenarioSeed}:tick-interval:${tickNumber}`); + return minimum + Math.floor(random() * (maximum - minimum + 1)); +} + function shuffled(values: readonly T[], seed: string): T[] { const result = [...values]; const random = seededNumber(seed); @@ -1121,6 +1169,8 @@ export function defaultWorldSetupRequest(): WorldSetupRequest { spawnSeed: DEFAULT_SPAWN_SEED, minimumSpawnSeparation: 1, communicationRangeKm: DEFAULT_COMMUNICATION_RANGE_KM, + minimumTickIntervalMinutes: DEFAULT_MINIMUM_TICK_INTERVAL_MINUTES, + maximumTickIntervalMinutes: DEFAULT_MAXIMUM_TICK_INTERVAL_MINUTES, patientZeroAgentId: null, roster, modelConfiguration: { @@ -1250,6 +1300,7 @@ export function previewWorldSetup( }; const scenario: AppliedScenario = { ...request, + decisionContractVersion: AGENT_DECISION_CONTRACT_VERSION, exactCellCount: cells.length, areaSquareKilometers: cells.reduce( (total, cell) => total + cellArea(cell, UNITS.km2), diff --git a/packages/world-engine/src/scenario.test.ts b/packages/world-engine/src/scenario.test.ts index 5a5138f..6c101ad 100644 --- a/packages/world-engine/src/scenario.test.ts +++ b/packages/world-engine/src/scenario.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { assignBehavior, WORLD_RADIUS_PRESETS } from '@hexzero/shared'; +import { + AGENT_DECISION_CONTRACT_VERSION, + assignBehavior, + WORLD_RADIUS_PRESETS, +} from '@hexzero/shared'; import { DEVELOPMENT_AGENT_BLUEPRINTS, allocateDeterministicSpawns, @@ -20,6 +24,9 @@ describe('configurable world scenarios', () => { ); expect(first).toEqual(second); expect(first.feasible && first.scenario.exactCellCount).toBe(127); + expect(first.feasible && first.scenario.decisionContractVersion).toBe( + AGENT_DECISION_CONTRACT_VERSION, + ); expect( first.feasible && first.world.agents.map(({ id, currentCell }) => ({ id, currentCell })), diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index 3bb52be..df0eaca 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -2,8 +2,8 @@ import { expect, test } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { experimentExportDocumentSchema } from '@hexzero/shared'; -const ROOK_ID = '2507bb46-7ae4-45ca-8dda-644c4f85ca14'; const EMBER_ID = '128f3f38-6b7d-4db7-9e95-751b4ce2681e'; +const CIPHER_ID = '89ce9ddb-611f-4a46-8f7b-36e656494aa2'; const MINGLE_PERSONALITY = 'You are a social coalition-builder. Seek agents, initiate and continue conversations, propose alliances, answer offers, negotiate borders, and coordinate captures against dominant rivals. Prefer cooperation and public diplomacy over silent expansion, but protect your own territory and leave an alliance that repeatedly ignores or exploits you. Make concrete proposals rather than merely announcing actions.'; @@ -137,9 +137,8 @@ test('runs the complete deterministic World Lab browser flow', async ({ 'Mingle0', ); - const followTurn = page.getByRole('checkbox', { name: 'Follow turn' }); + const followTurn = page.getByRole('checkbox', { name: 'Follow latest' }); await expect(followTurn).toBeChecked(); - await expect(page.getByLabel('Agent roster').getByText('Next')).toBeVisible(); await page.getByRole('button', { name: 'Collapse activity' }).click(); await expect(page.locator('.bottom-dock')).toHaveCSS('height', '54px'); @@ -177,15 +176,13 @@ test('runs the complete deterministic World Lab browser flow', async ({ ).toBeVisible(); await followTurn.check(); - await expect(page.getByLabel('Agent roster').getByText('Next')).toBeVisible(); - await page.getByRole('button', { name: 'Single turn' }).click(); - await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); + await page.getByRole('button', { name: 'Single tick' }).click(); await page.getByRole('button', { name: 'Select agent Ember' }).click(); await expect(followTurn).not.toBeChecked(); await page.getByRole('tab', { name: 'Event log' }).click(); - await expect( - page.getByText(/Infection .* direct message accepted/), - ).toBeVisible(); + const tickActivity = page.getByText(/Infection .* direct message accepted/); + await expect(tickActivity).toHaveCount(8); + await expect(tickActivity.first()).toBeVisible(); await expect(page.getByLabel('Direct-message history')).toContainText( 'Sent Rook', ); @@ -199,17 +196,17 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '127'); await expect(worldMap).toHaveAttribute( 'data-rendered-infected-cell-count', - '1', + '8', ); await expect(page.getByTestId('infected-count')).toHaveText( - '1 rendered infected', + '8 rendered infected', ); await page.getByRole('button', { name: 'Select agent Rook' }).click(); await expect(page.getByLabel('Direct-message history')).toContainText( 'Received Ember', ); - await page.getByRole('button', { name: 'Single turn' }).click(); + await page.getByRole('button', { name: 'Single tick' }).click(); const recipientObservation = page.getByText('Latest structured observation'); await recipientObservation.click(); await expect( @@ -221,8 +218,7 @@ test('runs the complete deterministic World Lab browser flow', async ({ await page.getByRole('tab', { name: 'Scoreboard' }).click(); await expect(page.getByLabel('Territory scoreboard')).toContainText('Ember1'); await page.getByRole('tab', { name: 'Agent' }).click(); - for (let index = 0; index < 8; index += 1) - await page.getByRole('button', { name: 'Single turn' }).click(); + await page.getByRole('button', { name: 'Single tick' }).click(); await page.getByRole('tab', { name: 'Scoreboard' }).click(); await expect(page.getByLabel('Alliance and territory panel')).toContainText( 'Mingle', @@ -239,8 +235,7 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect( page.getByRole('button', { name: 'Select agent Solace' }), ).toHaveAttribute('data-effective-color', '#0072B2'); - for (let index = 0; index < 8; index += 1) - await page.getByRole('button', { name: 'Single turn' }).click(); + await page.getByRole('button', { name: 'Single tick' }).click(); await page.getByRole('tab', { name: /Private comms/ }).click(); const privateComms = page.getByRole('tabpanel', { name: 'Private communications', @@ -250,41 +245,50 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect(privateComms).toContainText('Coordinate privately'); await privateComms.getByRole('button', { name: 'Direct' }).click(); await expect(privateComms).toContainText('Meet near the center'); - await privateComms.getByRole('button', { name: 'Rook' }).click(); + const rookParticipants = privateComms.getByRole('button', { name: 'Rook' }); + await expect(rookParticipants).toHaveCount(3); + await rookParticipants.first().click(); await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); await page.getByRole('tab', { name: 'Event log' }).click(); await expect( page.getByRole('list', { name: 'World event log' }), ).not.toContainText('Coordinate privately'); await page.getByRole('tab', { name: 'Agent' }).click(); - await expect(page.getByLabel('Agent inspector')).toContainText('move →'); + await expect(page.getByLabel('Agent inspector')).toContainText( + 'World action: wait · accepted', + ); let captured = false; for (let index = 0; index < 60 && !captured; index += 1) { - await page.getByRole('button', { name: 'Single turn' }).click(); - captured = await page.getByText(/Rook captured .* from Ember/).isVisible(); + await page.getByRole('button', { name: 'Single tick' }).click(); + captured = await page + .getByText(/captured .* from/) + .first() + .isVisible(); } expect(captured).toBe(true); await expect(worldMap).toHaveAttribute('data-controller-colors', /#b2d3a8/); await page.getByRole('tab', { name: 'Scoreboard' }).click(); - await expect(page.getByLabel('Territory scoreboard')).toContainText('Ember0'); + await expect(page.getByLabel('Territory scoreboard')).toContainText('Ember2'); await expect(page.getByLabel('Territory scoreboard')).toContainText('Rook1'); + await expect(page.getByLabel('Territory scoreboard')).toContainText( + 'Cipher0', + ); const agentRoster = page.getByLabel('Agent roster'); await agentRoster.getByRole('button', { name: /^Ember / }).click(); await expect(page.getByLabel('Recent territory changes')).toContainText( - 'Lost', + 'Gained', ); - await agentRoster.getByRole('button', { name: /^Rook / }).click(); + await agentRoster.getByRole('button', { name: /^Cipher / }).click(); await expect(page.getByLabel('Recent territory changes')).toContainText( - 'Gained', + 'Lost', ); - await agentRoster.getByRole('button', { name: /^Ember / }).click(); await openActions(); await page.getByRole('button', { name: 'Export' }).click(); await page.getByRole('button', { name: 'Clear' }).click(); - await page.getByRole('checkbox', { name: 'Ember', exact: true }).click(); + await page.getByRole('checkbox', { name: 'Cipher', exact: true }).click(); await expect( - page.getByRole('checkbox', { name: 'Ember', exact: true }), + page.getByRole('checkbox', { name: 'Cipher', exact: true }), ).toBeChecked(); const exportDialog = page.getByRole('dialog', { name: 'Experiment export' }); const exportGeometry = await exportDialog.evaluate((dialog) => ({ @@ -325,14 +329,14 @@ test('runs the complete deterministic World Lab browser flow', async ({ const exported = experimentExportDocumentSchema.parse( JSON.parse(await readFile(downloadedPath!, 'utf8')), ); - expect(exported.schemaVersion).toBe(9); + expect(exported.schemaVersion).toBe(10); expect(exported.filters.level).toBe('minimal'); - expect(exported.selection.selectedAgentIds).toEqual([EMBER_ID]); + expect(exported.selection.selectedAgentIds).toEqual([CIPHER_ID]); expect(exported.turns).toEqual([]); expect(exported.controlChanges).toMatchObject([ { - controllerAgentId: ROOK_ID, - previousControllerAgentId: EMBER_ID, + controllerAgentId: EMBER_ID, + previousControllerAgentId: CIPHER_ID, }, ]); expect(exported.selection.matchingControlChangeCount).toBe(1); @@ -353,13 +357,13 @@ test('runs the complete deterministic World Lab browser flow', async ({ page.getByRole('dialog', { name: 'Experiment export' }), ).toBeHidden(); await expect( - page.getByRole('button', { name: 'Select agent Ember' }), + page.getByRole('button', { name: 'Select agent Cipher' }), ).toHaveClass(/selected/); page.once('dialog', (dialog) => dialog.accept()); await openActions(); await page.getByRole('button', { name: 'Reset world' }).click(); - await expect(page.getByText('Turn 0')).toBeVisible(); + await expect(page.getByText('Tick 0')).toBeVisible(); await expect( page.getByText(customPersonality, { exact: true }), ).toBeVisible(); @@ -384,16 +388,18 @@ test('runs the complete deterministic World Lab browser flow', async ({ page.getByText('No direct messages for this agent yet.'), ).toBeVisible(); - await page.getByRole('button', { name: 'Single turn' }).click(); - await expect(page.getByText('Turn 1', { exact: true })).toBeVisible(); - await expect( - page.getByText(/Infection .* direct message accepted/), - ).toBeVisible(); - await page.getByRole('button', { name: 'Single turn' }).click(); - await expect(page.getByText('Turn 2', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Single tick' }).click(); + await expect(page.getByText('Tick 1', { exact: true })).toBeVisible(); + const resetTickActivity = page.getByText( + /Infection .* direct message accepted/, + ); + await expect(resetTickActivity).toHaveCount(8); + await expect(resetTickActivity.first()).toBeVisible(); + await page.getByRole('button', { name: 'Single tick' }).click(); + await expect(page.getByText('Tick 2', { exact: true })).toBeVisible(); await expect(worldMap).toHaveAttribute( 'data-rendered-infected-cell-count', - '1', + '8', ); page.once('dialog', (dialog) => dialog.accept()); await openActions(); @@ -407,11 +413,11 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect( page.getByText(MINGLE_PERSONALITY, { exact: true }), ).toBeVisible(); - await expect(page.getByText('Turn 2', { exact: true })).toBeVisible(); + await expect(page.getByText('Tick 2', { exact: true })).toBeVisible(); await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '127'); await expect(worldMap).toHaveAttribute( 'data-rendered-infected-cell-count', - '1', + '8', ); await expect(markers).toHaveCount(8); await page.setViewportSize({ width: 768, height: 900 });