Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -37,9 +40,27 @@ experiment archive path.

Open the World Lab at <http://localhost:3000>. The Game API binds to <http://127.0.0.1:8787>; 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.

Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 117 additions & 3 deletions apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
restoreDefaultPersonalitiesResponseSchema,
simulationSnapshotSchema,
singleTurnResponseSchema,
singleTickResponseSchema,
updateAgentPersonalityResponseSchema,
updateExperimentModelsResponseSchema,
verifyModelResponseSchema,
Expand Down Expand Up @@ -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<ProviderDecision> {
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<ProviderDecision> {
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({
Expand Down Expand Up @@ -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<void>((resolve) => {
requestStarted = resolve;
Expand All @@ -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;
Expand All @@ -160,6 +273,7 @@ describe('game API simulation boundary', () => {
snapshot: {
status: 'paused',
turnNumber: 0,
tickNumber: 0,
turns: [],
experiment: { totalCompletedTurns: 0 },
},
Expand Down
63 changes: 62 additions & 1 deletion apps/game-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
restoreDefaultPersonalitiesResponseSchema,
simulationSnapshotSchema,
singleTurnResponseSchema,
singleTickResponseSchema,
updateAgentPersonalityRequestSchema,
updateAgentPersonalityResponseSchema,
updateExperimentModelsRequestSchema,
Expand Down Expand Up @@ -109,7 +110,12 @@ export function createApp(options: AppOptions = {}) {
const mutationPromise = <T>(
context: Context,
operation:
'turn' | 'retry' | 'unattended-retry' | 'unattended-skip' | 'setup',
| 'turn'
| 'tick'
| 'retry'
| 'unattended-retry'
| 'unattended-skip'
| 'setup',
execute: () => Promise<T>,
): Promise<T> => {
const supplied =
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Loading
Loading