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() {
) : (
{cancelling ? (
@@ -1268,20 +1061,18 @@ export function WorldLab() {
)}
void cancelCurrentRequest()}
>
@@ -1290,41 +1081,8 @@ export function WorldLab() {
: 'Cancel'}
-
- void executeTurn('retry')}
- >
- Retry
-
- void skipFailedTurn()}
- >
- Skip turn
-
+
+ Each tick requests every active agent and may incur provider cost.
@@ -1336,46 +1094,10 @@ export function WorldLab() {
-
{
if (overflowMenuRef.current)
overflowMenuRef.current.open = false;
@@ -1425,7 +1147,9 @@ export function WorldLab() {
void reset()}
>
@@ -1446,7 +1170,7 @@ export function WorldLab() {
{uiError ??
(fullyInfected
- ? 'Development world fully infected. Automatic playback is paused; Single turn remains a manual cost-incurring diagnostic action.'
+ ? 'Development world fully infected. Automatic playback is paused; Single tick remains a manual cost-incurring diagnostic action.'
: (personalityNotice ??
(reasoningUnavailable
? 'A saved reasoning profile is no longer advertised by its model. Select an available profile before starting.'
@@ -1644,8 +1368,6 @@ export function WorldLab() {
snapshot={snapshot}
status={status}
runTarget={boundedRunTarget ?? runTarget}
- unattendedRetryCount={unattendedRetryCount}
- unattendedRetryLimit={unattendedRetryLimit}
/>
)}
@@ -1913,22 +1635,12 @@ function RunHealthSummary({
snapshot,
status,
runTarget,
- unattendedRetryCount,
- unattendedRetryLimit,
}: {
snapshot: SimulationSnapshot;
status: string;
runTarget: number;
- unattendedRetryCount: number;
- unattendedRetryLimit: number;
}) {
const metrics = snapshot.experiment.metrics.aggregate;
- const active = snapshot.world.agents.find(
- ({ id }) => id === snapshot.activeAgentId,
- );
- const next = snapshot.world.agents.find(
- ({ id }) => id === snapshot.nextAgentId,
- );
const elapsedMs = Math.max(
0,
Date.parse(
@@ -1944,16 +1656,20 @@ function RunHealthSummary({
@@ -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.