diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts
index bb1aeb5..8b583b7 100644
--- a/apps/game-api/src/experiment-export.ts
+++ b/apps/game-api/src/experiment-export.ts
@@ -128,9 +128,12 @@ export class ExperimentMetricAccumulator {
turn.outcome === 'accepted' &&
turn.worldActionResult.event.type === 'hex-captured'
) {
- const displaced = this.#records.get(
- turn.worldActionResult.event.previousControllerAgentId,
- );
+ const previousControllerAgentId =
+ turn.worldActionResult.event.previousControllerAgentId;
+ const displaced =
+ previousControllerAgentId === null
+ ? undefined
+ : this.#records.get(previousControllerAgentId);
if (displaced) displaced.territoryLostThroughCapture += 1;
}
}
@@ -758,9 +761,28 @@ export function createExperimentExport(
.map(({ tickNumber }) => tickNumber)
.filter((tick): tick is number => tick !== undefined),
...(exportedSwarmTicks ?? []).map(({ tickNumber }) => tickNumber),
+ ...source.simulatedPlayerEvents
+ .filter(
+ (event) =>
+ (request.agents.mode === 'all' &&
+ request.turns.mode === 'entire-retained') ||
+ (event.type === 'simulated-player-agent-captured' &&
+ selectedSet.has(event.capturedAgentId)),
+ )
+ .map(({ originatingTick }) => originatingTick),
]);
- const selectedAgents = source.currentAgents
- .filter(({ id }) => selectedSet.has(id))
+ const currentAgentsById = new Map(
+ source.currentAgents.map((agent) => [agent.id, agent]),
+ );
+ const initialAgentsById = new Map(
+ source.initialAgents.map((agent) => [agent.id, agent]),
+ );
+ const selectedAgents = selectedAgentIds
+ .map(
+ (agentId) =>
+ currentAgentsById.get(agentId) ?? initialAgentsById.get(agentId),
+ )
+ .filter((agent): agent is Agent => agent !== undefined)
.map((agent) =>
include.personality
? structuredClone(agent)
@@ -816,12 +838,22 @@ export function createExperimentExport(
lastMatchingTurn: filtered.at(-1)?.turnNumber,
},
agents: selectedAgents,
- currentGoals: source.agentGoals
- .filter(({ agentId }) => selectedSet.has(agentId))
- .map((entry) => structuredClone(entry)),
- currentMemories: source.agentMemories
- .filter(({ agentId }) => selectedSet.has(agentId))
- .map((entry) => structuredClone(entry)),
+ currentGoals: selectedAgentIds.map((agentId) =>
+ structuredClone(
+ source.agentGoals.find((entry) => entry.agentId === agentId) ?? {
+ agentId,
+ goal: null,
+ },
+ ),
+ ),
+ currentMemories: selectedAgentIds.map((agentId) =>
+ structuredClone(
+ source.agentMemories.find((entry) => entry.agentId === agentId) ?? {
+ agentId,
+ entries: [],
+ },
+ ),
+ ),
configurationEvents: source.configurationEvents
.filter((event) =>
'type' in event
@@ -919,7 +951,7 @@ function exportWorldState(world: WorldSnapshot): ExperimentExportWorldState {
function currentTerritory(world: WorldSnapshot, agents: readonly Agent[]) {
const counts = new Map(agents.map(({ id }) => [id, 0]));
for (const hex of world.hexes) {
- if (hex.state === 'infected')
+ if (hex.state === 'infected' && hex.controllerAgentId !== null)
counts.set(
hex.controllerAgentId,
(counts.get(hex.controllerAgentId) ?? 0) + 1,
@@ -1040,11 +1072,12 @@ function resolveAgentIds(
source: ExperimentSource,
request: ExperimentExportRequest,
): AgentId[] {
- const known = new Set(source.currentAgents.map(({ id }) => id));
+ const known = new Set([
+ ...source.initialAgents.map(({ id }) => id),
+ ...source.currentAgents.map(({ id }) => id),
+ ]);
const selected =
- request.agents.mode === 'all'
- ? source.currentAgents.map(({ id }) => id)
- : request.agents.agentIds;
+ request.agents.mode === 'all' ? [...known] : request.agents.agentIds;
if (selected.some((id) => !known.has(id))) {
throw new ExperimentExportValidationError(
'unknown_agent',
@@ -1188,7 +1221,10 @@ function filterControlChanges(
turn.outcome !== 'accepted' ||
turn.worldActionResult.event.type !== 'hex-captured' ||
(!selected.has(turn.worldActionResult.event.controllerAgentId) &&
- !selected.has(turn.worldActionResult.event.previousControllerAgentId))
+ (turn.worldActionResult.event.previousControllerAgentId === null ||
+ !selected.has(
+ turn.worldActionResult.event.previousControllerAgentId,
+ )))
)
return [];
return [
@@ -1733,8 +1769,10 @@ export function calculateExperimentMetrics(
({ previousControllerAgentId }) =>
previousControllerAgentId === agentId,
).length
- : relevantControlChanges.filter(({ previousControllerAgentId }) =>
- scopedAgentIds.includes(previousControllerAgentId),
+ : relevantControlChanges.filter(
+ ({ previousControllerAgentId }) =>
+ previousControllerAgentId !== null &&
+ scopedAgentIds.includes(previousControllerAgentId),
).length,
...communicationMetrics(relevantCommunications, scopedAgentIds, agentId),
mostRecentZeroDirective,
diff --git a/apps/game-api/src/observation-history.ts b/apps/game-api/src/observation-history.ts
index cbb6155..6b11cad 100644
--- a/apps/game-api/src/observation-history.ts
+++ b/apps/game-api/src/observation-history.ts
@@ -73,12 +73,13 @@ export class ObservationHistory {
event,
RECENT_CONTROL_CHANGE_LIMIT,
);
- appendFor(
- this.#controlChanges,
- event.previousControllerAgentId,
- event,
- RECENT_CONTROL_CHANGE_LIMIT,
- );
+ if (event.previousControllerAgentId !== null)
+ appendFor(
+ this.#controlChanges,
+ event.previousControllerAgentId,
+ event,
+ RECENT_CONTROL_CHANGE_LIMIT,
+ );
break;
case 'public-message-sent':
this.#publicMessages = append(
@@ -135,6 +136,7 @@ export class ObservationHistory {
case 'simulated-player-moved':
case 'hex-disinfected':
case 'simulated-player-clean-blocked':
+ case 'simulated-player-agent-captured':
break;
}
}
diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts
index 9f9d1ed..428496c 100644
--- a/apps/game-api/src/reflex-execution.test.ts
+++ b/apps/game-api/src/reflex-execution.test.ts
@@ -81,6 +81,29 @@ describe('zero-swarm reflex execution seam', () => {
expect(applied.state.agents.get(agent.id)?.currentCell).toBe(targetCell);
});
+ it('passes bounded authoritative capture facts to Jev without player route data', () => {
+ const { state, directive } = fixture();
+ const compiled = compileReflexObservation(state, directive, {
+ captureAlerts: [
+ {
+ capturedAgentId: [...state.agents.keys()][0]!,
+ cell: directive.targetCell!,
+ originatingTick: 4,
+ abandonedCellCount: 2,
+ },
+ ],
+ });
+ expect(compiled.observation.captureAlerts).toEqual([
+ {
+ capturedAgentId: [...state.agents.keys()][0]!,
+ cell: directive.targetCell,
+ originatingTick: 4,
+ abandonedCellCount: 2,
+ },
+ ]);
+ expect(compiled.observation).not.toHaveProperty('simulatedPlayer');
+ });
+
it('retains a completed provider attempt even when no world state is committed', async () => {
const { state, directive } = fixture();
const accounting = new AttemptAccounting(10);
diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts
index 64a3579..c98caec 100644
--- a/apps/game-api/src/reflex-execution.ts
+++ b/apps/game-api/src/reflex-execution.ts
@@ -9,6 +9,7 @@ import {
reflexObservationSchema,
swarmDirectiveSchema,
type H3Cell,
+ type CaptureAlert,
type ProviderFailure,
type ProviderMetadata,
type ReflexDecision,
@@ -28,6 +29,7 @@ export interface ReflexLocalHistory {
recentCleanedCells?: readonly H3Cell[];
territoryDelta?: number;
recentActionOutcome?: 'success' | 'rejected' | 'unknown';
+ captureAlerts?: readonly CaptureAlert[];
}
export interface CompiledReflexObservation {
@@ -165,6 +167,9 @@ export function compileReflexObservation(
recentActionOutcome: history.recentActionOutcome ?? 'unknown',
},
relevantRecentFacts: [],
+ ...(history.captureAlerts?.length
+ ? { captureAlerts: [...history.captureAlerts].slice(-4) }
+ : {}),
candidates,
});
return { observation, actions };
diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts
index 0d728ba..44256ea 100644
--- a/apps/game-api/src/simulation-service.swarm.test.ts
+++ b/apps/game-api/src/simulation-service.swarm.test.ts
@@ -9,14 +9,17 @@ import {
type SwarmPlanner,
} from '@hexzero/agent-runtime';
import {
+ assignBehavior,
type CompatibleModel,
type SwarmPlan,
type ZeroStrategicObservation,
reflexDecisionSchema,
singleTickResponseSchema,
} from '@hexzero/shared';
+import { generateDeterministicRoster } from '@hexzero/world-engine';
import { createApp } from './app';
import {
+ SimulationConflictError,
SimulationService,
SimulationTurnCancelledError,
} from './simulation-service';
@@ -134,6 +137,187 @@ function setup(
}
describe('zero-swarm SimulationService tick', () => {
+ it('commits a player-only terminal tick when trail hunter captures Patient Zero', async () => {
+ const planner = new InspectingPlanner();
+ const simulation = setup(
+ planner,
+ new ScriptedReflexProvider([{ chosenCandidateId: 'action_0' }]),
+ );
+ const request = simulation.getDefaultWorldSetup();
+ const roster = generateDeterministicRoster(1, 'terminal-roster');
+ simulation.applyWorldSetup({
+ ...request,
+ cognitionMode: 'zero-swarm-v1',
+ roster,
+ patientZeroAgentId: roster[0]!.id,
+ spawnSeed: 'terminal-spawn',
+ objectiveVersion: 'durable-influence-v3',
+ capabilities: { ...request.capabilities, simulatedPlayerPressure: true },
+ simulatedPlayer: {
+ enabled: true,
+ profile: 'trail-hunter-v1',
+ seed: 'terminal-spawn',
+ },
+ modelConfiguration: {
+ globalModelId: model.id,
+ globalReasoningProfile: 'low',
+ overrides: [],
+ locked: false,
+ },
+ behaviorConfiguration: {
+ ...request.behaviorConfiguration,
+ assignments: assignBehavior(
+ roster.map(({ id }) => id),
+ request.behaviorConfiguration.seed,
+ 'balanced-random',
+ ),
+ },
+ });
+
+ await expect(simulation.executeNextTick()).resolves.toEqual([]);
+ const snapshot = simulation.getSnapshot();
+ expect(snapshot.status).toBe('infection-eliminated');
+ expect(snapshot.tickNumber).toBe(1);
+ expect(snapshot.resolutionOrder).toEqual([]);
+ expect(snapshot.nextAgentId).toBeNull();
+ expect(snapshot.world.agents).toEqual([]);
+ expect(snapshot.world.events).toContainEqual(
+ expect.objectContaining({ type: 'simulated-player-agent-captured' }),
+ );
+ expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(0);
+ expect(planner.observations).toEqual([]);
+ const exported = simulation.generateExperimentExport({
+ agents: { mode: 'all' },
+ turns: { mode: 'entire-retained' },
+ outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'],
+ actions: ['move', 'infect', 'capture', 'wait'],
+ communications: { channel: 'all', status: 'all' },
+ level: 'full-safe',
+ serialization: 'compact',
+ });
+ expect(exported.selection.selectedAgentIds).toEqual([roster[0]!.id]);
+ expect(exported.agents).toEqual([
+ expect.objectContaining({ id: roster[0]!.id }),
+ ]);
+ expect(exported.worldEvents).toContainEqual(
+ expect.objectContaining({
+ type: 'simulated-player-agent-captured',
+ capturedAgentId: roster[0]!.id,
+ }),
+ );
+ await expect(simulation.executeNextTick()).rejects.toBeInstanceOf(
+ SimulationConflictError,
+ );
+ });
+
+ it('removes a captured worker before Zero plans or any worker reflex runs', async () => {
+ const planner = new InspectingPlanner();
+ const simulation = setup(
+ planner,
+ new ScriptedReflexProvider([{ chosenCandidateId: 'action_0' }]),
+ );
+ const request = simulation.getDefaultWorldSetup();
+ const roster = generateDeterministicRoster(2, 'worker-capture-roster');
+ simulation.applyWorldSetup({
+ ...request,
+ cognitionMode: 'zero-swarm-v1',
+ roster,
+ patientZeroAgentId: roster[1]!.id,
+ spawnSeed: 'worker-capture-spawn',
+ objectiveVersion: 'durable-influence-v3',
+ capabilities: { ...request.capabilities, simulatedPlayerPressure: true },
+ simulatedPlayer: {
+ enabled: true,
+ profile: 'trail-hunter-v1',
+ seed: 'worker-capture-spawn',
+ },
+ modelConfiguration: {
+ globalModelId: model.id,
+ globalReasoningProfile: 'low',
+ overrides: [],
+ locked: false,
+ },
+ behaviorConfiguration: {
+ ...request.behaviorConfiguration,
+ assignments: assignBehavior(
+ roster.map(({ id }) => id),
+ request.behaviorConfiguration.seed,
+ 'balanced-random',
+ ),
+ },
+ });
+
+ await simulation.executeNextTick();
+ const snapshot = simulation.getSnapshot();
+ expect(snapshot.status).toBe('paused');
+ expect(snapshot.world.agents.map(({ id }) => id)).toEqual([roster[1]!.id]);
+ expect(snapshot.swarmTicks?.[0]?.workers).toEqual([]);
+ expect(
+ planner.observations[0]?.agents.map(({ agentId }) => agentId),
+ ).toEqual([roster[1]!.id]);
+ expect(planner.observations[0]?.recentPlayerPressure).toEqual(
+ expect.arrayContaining([expect.stringContaining(roster[0]!.id)]),
+ );
+ expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(1);
+ });
+
+ it('keeps provider attempt turn numbers unique after a worker capture', async () => {
+ const simulation = setup(
+ new InspectingPlanner(),
+ new ScriptedReflexProvider(
+ Array.from({ length: 2 }, () => ({ chosenCandidateId: 'action_0' })),
+ ),
+ );
+ const request = simulation.getDefaultWorldSetup();
+ const roster = generateDeterministicRoster(3, 'attempt-capture-roster');
+ simulation.applyWorldSetup({
+ ...request,
+ cognitionMode: 'zero-swarm-v1',
+ roster,
+ patientZeroAgentId: roster[1]!.id,
+ spawnSeed: 'attempt-capture-spawn',
+ objectiveVersion: 'durable-influence-v3',
+ capabilities: { ...request.capabilities, simulatedPlayerPressure: true },
+ simulatedPlayer: {
+ enabled: true,
+ profile: 'trail-hunter-v1',
+ seed: 'attempt-capture-spawn',
+ },
+ modelConfiguration: {
+ globalModelId: model.id,
+ globalReasoningProfile: 'low',
+ overrides: [],
+ locked: false,
+ },
+ behaviorConfiguration: {
+ ...request.behaviorConfiguration,
+ assignments: assignBehavior(
+ roster.map(({ id }) => id),
+ request.behaviorConfiguration.seed,
+ 'balanced-random',
+ ),
+ },
+ });
+
+ await simulation.executeNextTick();
+ await simulation.executeNextTick();
+ const exported = simulation.generateExperimentExport({
+ agents: { mode: 'all' },
+ turns: { mode: 'entire-retained' },
+ outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'],
+ actions: ['move', 'infect', 'capture', 'wait'],
+ communications: { channel: 'all', status: 'all' },
+ level: 'full-safe',
+ serialization: 'compact',
+ });
+ const attempts = exported.providerAttempts ?? [];
+ const intendedTurns = attempts.map(
+ ({ intendedTurnNumber }) => intendedTurnNumber,
+ );
+ expect(new Set(intendedTurns).size).toBe(intendedTurns.length);
+ expect(attempts.map(({ agentId }) => agentId)).not.toContain(roster[0]!.id);
+ });
+
it('reports swarm providers without requiring the unused legacy provider', () => {
const legacy: AgentProvider = {
mode: 'openrouter',
diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts
index 64d63c0..860d723 100644
--- a/apps/game-api/src/simulation-service.ts
+++ b/apps/game-api/src/simulation-service.ts
@@ -75,6 +75,7 @@ import {
type SwarmTickRecord,
type ZeroStrategicObservation,
type PatientZeroPressureContext,
+ type CaptureAlert,
worldSetupRequestSchema,
type AppliedScenario,
type WorldSetupPreviewResponse,
@@ -98,7 +99,7 @@ import {
expireAllianceProposals,
seededTickIntervalMinutes,
seededTickOrder,
- advanceCasualCleaner,
+ advanceSimulatedPlayer,
enumerateLegalWorldActions,
toWorldState,
type WorldState,
@@ -165,10 +166,12 @@ export function calculatePatientZeroPressureContext(
> =>
(event.type === 'hex-disinfected' ||
event.type === 'simulated-player-clean-blocked') &&
+ (event.type !== 'hex-disinfected' ||
+ event.previousControllerAgentId !== null) &&
event.originatingTick >= startTick &&
event.originatingTick <= currentTick,
);
- const eventSubject = (event: (typeof relevant)[number]): AgentId =>
+ const eventSubject = (event: (typeof relevant)[number]): AgentId | null =>
event.type === 'hex-disinfected'
? event.previousControllerAgentId
: event.blockingAgentId;
@@ -212,12 +215,36 @@ export function calculatePatientZeroPressureContext(
},
currentAlliance: memberIds
? countsFor(
- relevant.filter((event) => memberIds.has(eventSubject(event))),
+ relevant.filter((event) => {
+ const subject = eventSubject(event);
+ return subject !== null && memberIds.has(subject);
+ }),
)
: null,
};
}
+function captureAlertsFrom(
+ events: readonly SimulatedPlayerEvent[],
+): CaptureAlert[] {
+ return events
+ .filter(
+ (
+ event,
+ ): event is Extract<
+ SimulatedPlayerEvent,
+ { type: 'simulated-player-agent-captured' }
+ > => event.type === 'simulated-player-agent-captured',
+ )
+ .slice(-4)
+ .map(({ capturedAgentId, cell, originatingTick, abandonedCellCount }) => ({
+ capturedAgentId,
+ cell,
+ originatingTick,
+ abandonedCellCount,
+ }));
+}
+
interface PendingFailedTurn {
turnNumber: number;
agentId: AgentId;
@@ -286,6 +313,7 @@ export class SimulationService {
#state: WorldState;
#turns: AgentTurnRecord[] = [];
#completedTurnCount = 0;
+ #completedSwarmDecisionCount = 0;
#completedTickCount = 0;
#virtualTime = RESET_GENERATED_AT;
#lastTickIntervalMinutes: number | null = null;
@@ -398,7 +426,6 @@ export class SimulationService {
getSnapshot(): SimulationSnapshot {
const agents = [...this.#state.agents.values()];
const next = agents[this.#cursor % agents.length];
- if (!next) throw new Error('The development world has no agents.');
const droppedRecords =
this.#completedTurnCount - this.#experimentTurns.length;
return simulationSnapshotSchema.parse({
@@ -409,7 +436,7 @@ export class SimulationService {
virtualTime: this.#virtualTime,
lastTickIntervalMinutes: this.#lastTickIntervalMinutes,
resolutionOrder: this.#resolutionOrder,
- nextAgentId: next.id,
+ nextAgentId: next?.id ?? null,
activeAgentId: this.#activeAgentId,
cancellationRequested: this.#cancellationRequested,
pendingFailedTurn: this.#pendingFailedTurn
@@ -439,7 +466,9 @@ export class SimulationService {
}
: {}),
modelConfiguration: this.#modelConfiguration,
- behaviorConfiguration: this.#behaviorConfiguration,
+ ...(agents.length > 0
+ ? { behaviorConfiguration: this.#behaviorConfiguration }
+ : {}),
resolvedModels: agents.map(({ id }) => this.#resolvedModel(id)),
agentGoals: agents.map(({ id }) => ({
agentId: id,
@@ -487,6 +516,7 @@ export class SimulationService {
);
this.#turns = [];
this.#completedTurnCount = 0;
+ this.#completedSwarmDecisionCount = 0;
this.#completedTickCount = 0;
this.#virtualTime = RESET_GENERATED_AT;
this.#lastTickIntervalMinutes = null;
@@ -645,6 +675,7 @@ export class SimulationService {
this.#behaviorConfiguration = nextBehavior;
this.#turns = [];
this.#completedTurnCount = 0;
+ this.#completedSwarmDecisionCount = 0;
this.#completedTickCount = 0;
this.#virtualTime = RESET_GENERATED_AT;
this.#lastTickIntervalMinutes = null;
@@ -1136,7 +1167,37 @@ export class SimulationService {
throw new SimulationConflictError(
'A simultaneous tick cannot start inside a legacy sequential experiment. Reset first.',
);
- const agents = [...this.#state.agents.values()];
+ if (this.#isTerminal())
+ throw new SimulationConflictError(
+ 'This simulation has reached a terminal infection outcome. Reset before running another tick.',
+ );
+ const tickNumber = this.#completedTickCount + 1;
+ const preTickState = this.#state;
+ 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 playerAdvance = advanceSimulatedPlayer(
+ preTickState,
+ this.#scenario.simulatedPlayer.seed,
+ tickNumber,
+ { createEventId: this.#createEventId, now: () => virtualTime },
+ );
+ const agents = [...playerAdvance.state.agents.values()];
+ if (!agents.length) {
+ this.#commitTerminalPlayerTick(
+ playerAdvance,
+ tickNumber,
+ virtualTime,
+ interval,
+ );
+ return [];
+ }
const unresolved = agents
.map(({ id }) => this.#resolvedModel(id))
.filter(({ available }) => !available);
@@ -1153,28 +1214,11 @@ export class SimulationService {
);
}
- const tickNumber = this.#completedTickCount + 1;
- const preTickState = this.#state;
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 playerAdvance = advanceCasualCleaner(
- preTickState,
- this.#scenario.simulatedPlayer.seed,
- tickNumber,
- { createEventId: this.#createEventId, now: () => virtualTime },
- );
// Observation construction is synchronous. Temporarily point it at the
// uncommitted candidate so cancellation cannot expose or persist a partial
// player interval while every agent still observes the same frozen state.
@@ -1486,28 +1530,13 @@ export class SimulationService {
throw new SimulationConflictError(
'Zero-swarm execution requires a planner and reflex provider.',
);
- const zeroAgentId = this.#scenario.patientZeroAgentId;
- const agents = [...this.#state.agents.values()];
- const zero = zeroAgentId ? this.#state.agents.get(zeroAgentId) : undefined;
- if (!zero)
- throw new SimulationValidationError(
- 'invalid_model_configuration',
- 'Zero-swarm requires the designated Patient Zero.',
- );
- const resolvedZero = this.#resolvedModel(zero.id);
- if (!resolvedZero.available || !resolvedZero.modelId)
- throw new SimulationValidationError(
- 'models_unavailable',
- 'Agent Zero requires an available compatible model.',
+ if (this.#isTerminal())
+ throw new SimulationConflictError(
+ 'This simulation has reached a terminal infection outcome. Reset before running another tick.',
);
+ const zeroAgentId = this.#scenario.patientZeroAgentId;
const tickNumber = this.#completedTickCount + 1;
- const tickTurnBase = (tickNumber - 1) * agents.length;
const preTickState = this.#state;
- const order = seededTickOrder(
- agents.map(({ id }) => id),
- this.#scenario.worldSeed,
- tickNumber,
- );
const interval = seededTickIntervalMinutes(
this.#scenario.worldSeed,
tickNumber,
@@ -1517,17 +1546,46 @@ export class SimulationService {
const virtualTime = new Date(
new Date(this.#virtualTime).getTime() + interval * 60_000,
).toISOString();
- const playerAdvance = advanceCasualCleaner(
+ const playerAdvance = advanceSimulatedPlayer(
preTickState,
this.#scenario.simulatedPlayer.seed,
tickNumber,
{ createEventId: this.#createEventId, now: () => virtualTime },
);
const candidate = playerAdvance.state;
+ const agents = [...candidate.agents.values()];
+ const zero = zeroAgentId ? candidate.agents.get(zeroAgentId) : undefined;
+ if (!agents.length || !zero) {
+ this.#commitTerminalPlayerTick(
+ playerAdvance,
+ tickNumber,
+ virtualTime,
+ interval,
+ );
+ return [];
+ }
+ const resolvedZero = this.#resolvedModel(zero.id);
+ if (!resolvedZero.available || !resolvedZero.modelId)
+ throw new SimulationValidationError(
+ 'models_unavailable',
+ 'Agent Zero requires an available compatible model.',
+ );
+ const tickTurnBase = this.#completedSwarmDecisionCount;
+ const order = seededTickOrder(
+ agents.map(({ id }) => id),
+ this.#scenario.worldSeed,
+ tickNumber,
+ );
const replanReasons = this.#swarmReplanReasons(
tickNumber,
playerAdvance.events,
+ candidate,
);
+ if (
+ agents.length !== preTickState.agents.size &&
+ !replanReasons.includes('roster-changed')
+ )
+ replanReasons.push('roster-changed');
const replan = replanReasons.length > 0;
// Planning ticks reserve Zero plus workers; directive reuse only reserves workers.
if (!this.#attemptAccounting.reserve(agents.length - (replan ? 0 : 1))) {
@@ -1656,6 +1714,7 @@ export class SimulationService {
)
.slice(-6)
.map(({ cell }) => cell),
+ captureAlerts: captureAlertsFrom(playerAdvance.events),
territoryDelta:
this.#lastSwarmTerritoryDeltas.get(worker.id) ?? 0,
recentActionOutcome: this.#swarmTicks
@@ -1745,8 +1804,10 @@ export class SimulationService {
...state,
events: state.events.slice(-MAX_WORLD_EVENT_HISTORY),
};
+ this.#pruneCapturedRosterState();
this.#observationHistory.ingest(observationEvents);
this.#completedTickCount = tickNumber;
+ this.#completedSwarmDecisionCount += order.length;
this.#virtualTime = virtualTime;
this.#lastTickIntervalMinutes = interval;
this.#resolutionOrder = order;
@@ -1832,18 +1893,24 @@ export class SimulationService {
observationEvents: WorldEvent[],
): void {
this.#state = state;
+ this.#pruneCapturedRosterState();
this.#observationHistory.ingest(observationEvents);
this.#completedTickCount = tickNumber;
this.#virtualTime = virtualTime;
this.#lastTickIntervalMinutes = interval;
this.#resolutionOrder = [...order];
- this.#agentGoals = goals;
- this.#agentMemories = memories;
+ this.#agentGoals = new Map(
+ [...goals].filter(([agentId]) => this.#state.agents.has(agentId)),
+ );
+ this.#agentMemories = new Map(
+ [...memories].filter(([agentId]) => this.#state.agents.has(agentId)),
+ );
this.#simulatedPlayerEvents = [
...this.#simulatedPlayerEvents,
...structuredClone(playerEvents),
].slice(-this.#experimentRetentionLimit * 2);
- this.#completedTurnCount = records.at(-1)!.turnNumber;
+ this.#completedTurnCount =
+ records.at(-1)?.turnNumber ?? this.#completedTurnCount;
this.#turns = retainCompleteTickGroups(
[...this.#turns, ...records],
MAX_TURN_HISTORY,
@@ -1860,6 +1927,77 @@ export class SimulationService {
this.#modelConfiguration = { ...this.#modelConfiguration, locked: false };
}
+ #isTerminal(): boolean {
+ return (
+ this.#status === 'patient-zero-captured' ||
+ this.#status === 'infection-eliminated'
+ );
+ }
+
+ /** Commit a deterministic player interval that removed the active swarm. */
+ #commitTerminalPlayerTick(
+ playerAdvance: ReturnType,
+ tickNumber: number,
+ virtualTime: string,
+ interval: number,
+ ): void {
+ const patientZeroCaptured = playerAdvance.events.some(
+ (event) =>
+ event.type === 'simulated-player-agent-captured' &&
+ event.capturedAgentId === this.#scenario.patientZeroAgentId,
+ );
+ this.#state = {
+ ...playerAdvance.state,
+ events: playerAdvance.state.events.slice(-MAX_WORLD_EVENT_HISTORY),
+ };
+ this.#pruneCapturedRosterState();
+ this.#observationHistory.ingest(playerAdvance.events);
+ this.#completedTickCount = tickNumber;
+ this.#virtualTime = virtualTime;
+ this.#lastTickIntervalMinutes = interval;
+ this.#resolutionOrder = [];
+ this.#activeAgentId = null;
+ this.#pendingFailedTurn = null;
+ this.#simulatedPlayerEvents = [
+ ...this.#simulatedPlayerEvents,
+ ...structuredClone(playerAdvance.events),
+ ].slice(-this.#experimentRetentionLimit * 2);
+ this.#lastValidSwarmPlan = null;
+ this.#lastSwarmTerritoryCounts = new Map();
+ this.#lastSwarmTerritoryDeltas = new Map();
+ this.#lastSwarmPositions = new Map();
+ this.#status =
+ this.#state.agents.size === 0
+ ? 'infection-eliminated'
+ : patientZeroCaptured
+ ? 'patient-zero-captured'
+ : 'infection-eliminated';
+ }
+
+ /** Remove cognition state that belongs to agents captured by the engine. */
+ #pruneCapturedRosterState(): void {
+ const active = new Set(this.#state.agents.keys());
+ this.#agentGoals = new Map(
+ [...this.#agentGoals].filter(([agentId]) => active.has(agentId)),
+ );
+ this.#agentMemories = new Map(
+ [...this.#agentMemories].filter(([agentId]) => active.has(agentId)),
+ );
+ const assignments = this.#behaviorConfiguration.assignments.filter(
+ ({ agentId }) => active.has(agentId),
+ );
+ this.#behaviorConfiguration = {
+ ...this.#behaviorConfiguration,
+ assignments,
+ };
+ this.#modelConfiguration = {
+ ...this.#modelConfiguration,
+ overrides: this.#modelConfiguration.overrides.filter(({ agentId }) =>
+ active.has(agentId),
+ ),
+ };
+ }
+
async retryFailedTurn(
kind: 'manual-retry' | 'unattended-retry' = 'manual-retry',
): Promise {
@@ -2510,7 +2648,7 @@ export class SimulationService {
[...state.agents.keys()].map((id) => [id, 0]),
);
for (const hex of state.hexes.values())
- if (hex.state === 'infected')
+ if (hex.state === 'infected' && hex.controllerAgentId !== null)
counts.set(
hex.controllerAgentId,
(counts.get(hex.controllerAgentId) ?? 0) + 1,
@@ -2579,7 +2717,9 @@ export class SimulationService {
? 'A nearby infected cell was cleaned this tick.'
: event.type === 'simulated-player-clean-blocked'
? 'Cleaning pressure was blocked by an occupied infected cell.'
- : 'The simulated player moved this tick.',
+ : event.type === 'simulated-player-agent-captured'
+ ? `Worker ${event.capturedAgentId} was captured at ${event.cell}; ${event.abandonedCellCount} controlled cells became abandoned.`
+ : 'The simulated player moved this tick.',
),
...(replanReasons.length ? { replanReasons: [...replanReasons] } : {}),
...(workerReplanRequests.length ? { workerReplanRequests } : {}),
@@ -2591,9 +2731,21 @@ export class SimulationService {
#swarmReplanReasons(
tickNumber: number,
playerEvents: readonly SimulatedPlayerEvent[],
+ state: WorldState = this.#state,
): SwarmReplanReason[] {
if (!this.#lastValidSwarmPlan) return ['initial'];
const reasons: SwarmReplanReason[] = [];
+ const currentWorkers = [...state.agents.keys()]
+ .filter((agentId) => agentId !== this.#scenario.patientZeroAgentId)
+ .toSorted();
+ const plannedWorkers = this.#lastValidSwarmPlan.directives
+ .map(({ agentId }) => agentId)
+ .toSorted();
+ if (
+ currentWorkers.length !== plannedWorkers.length ||
+ currentWorkers.some((agentId, index) => agentId !== plannedWorkers[index])
+ )
+ reasons.push('roster-changed');
if ((tickNumber - 1) % 5 === 0) reasons.push('periodic-review');
if (
this.#lastValidSwarmPlan.directives.some(
@@ -2783,7 +2935,10 @@ export class SimulationService {
initialWorld: this.#initialExperimentWorld,
currentWorld: this.#worldSnapshot(),
modelConfiguration: this.#modelConfiguration,
- behaviorConfiguration: this.#behaviorConfiguration,
+ behaviorConfiguration:
+ this.#state.agents.size > 0
+ ? this.#behaviorConfiguration
+ : this.#scenario.behaviorConfiguration,
scenario: this.#scenario,
schemaVersion: 11,
providerAttempts: this.#attemptAccounting.ledger(),
@@ -2864,15 +3019,18 @@ export class SimulationService {
controllerAllianceId: null,
effectiveColor: null,
} as const;
+ const controller = state.controllerAgentId;
return {
cell,
...state,
controllerAllianceId:
- getAgentAlliance(this.#state, state.controllerAgentId)?.id ?? null,
- effectiveColor: getEffectiveAgentColor(
- this.#state,
- state.controllerAgentId,
- ),
+ controller === null
+ ? null
+ : (getAgentAlliance(this.#state, controller)?.id ?? null),
+ effectiveColor:
+ controller === null
+ ? null
+ : getEffectiveAgentColor(this.#state, controller),
} as const;
};
const adjacentCells = gridDisk(agent.currentCell, 1)
@@ -3011,22 +3169,25 @@ export class SimulationService {
});
const recentControlChanges = this.#observationHistory
.controlChanges(agent.id)
- .map((event) => {
+ .flatMap((event) => {
const gained = event.controllerAgentId === agent.id;
const otherAgentId = gained
? event.previousControllerAgentId
: event.controllerAgentId;
+ if (otherAgentId === null) return [];
const otherAgent = this.#state.agents.get(otherAgentId);
if (!otherAgent)
throw new Error('A control-change participant does not exist.');
- return {
- eventId: event.id,
- direction: gained ? ('gained' as const) : ('lost' as const),
- otherAgentId,
- otherAgentName: otherAgent.name,
- cell: event.cell,
- occurredAt: event.occurredAt,
- };
+ return [
+ {
+ eventId: event.id,
+ direction: gained ? ('gained' as const) : ('lost' as const),
+ otherAgentId,
+ otherAgentName: otherAgent.name,
+ cell: event.cell,
+ occurredAt: event.occurredAt,
+ },
+ ];
});
const completePlayerPressureEvents = [
...this.#simulatedPlayerEvents,
@@ -3081,11 +3242,12 @@ export class SimulationService {
left.occurredAt.localeCompare(right.occurredAt) ||
left.id.localeCompare(right.id),
)
- .map((event) => {
+ .flatMap((event) => {
const referencedAgentId =
event.type === 'hex-disinfected'
? event.previousControllerAgentId
: event.blockingAgentId;
+ if (referencedAgentId === null) return [];
const referencedAgent = this.#state.agents.get(referencedAgentId);
if (!referencedAgent)
throw new Error(
@@ -3098,31 +3260,34 @@ export class SimulationService {
alliance?.memberAgentIds ?? null,
this.#completedTickCount + 1,
);
- return event.type === 'hex-disinfected'
- ? {
- eventId: event.id,
- kind: 'territory-disinfected' as const,
- cell: event.cell,
- occurredAt: event.occurredAt,
- affectedAgentId: referencedAgent.id,
- affectedAgentName: referencedAgent.name,
- affectedAllianceId: alliance?.id ?? null,
- affectedAllianceColor: alliance?.color ?? null,
- pressureContext,
- }
- : {
- eventId: event.id,
- kind: 'occupied-clean-blocked' as const,
- cell: event.cell,
- occurredAt: event.occurredAt,
- blockingAgentId: referencedAgent.id,
- blockingAgentName: referencedAgent.name,
- blockingAllianceId: alliance?.id ?? null,
- blockingAllianceColor: alliance?.color ?? null,
- pressureContext,
- };
+ return [
+ event.type === 'hex-disinfected'
+ ? {
+ eventId: event.id,
+ kind: 'territory-disinfected' as const,
+ cell: event.cell,
+ occurredAt: event.occurredAt,
+ affectedAgentId: referencedAgent.id,
+ affectedAgentName: referencedAgent.name,
+ affectedAllianceId: alliance?.id ?? null,
+ affectedAllianceColor: alliance?.color ?? null,
+ pressureContext,
+ }
+ : {
+ eventId: event.id,
+ kind: 'occupied-clean-blocked' as const,
+ cell: event.cell,
+ occurredAt: event.occurredAt,
+ blockingAgentId: referencedAgent.id,
+ blockingAgentName: referencedAgent.name,
+ blockingAllianceId: alliance?.id ?? null,
+ blockingAllianceColor: alliance?.color ?? null,
+ pressureContext,
+ },
+ ];
})
: [];
+ const captureAlerts = captureAlertsFrom(currentCandidatePlayerEvents);
return agentObservationSchema.parse({
agentId: agent.id,
agentName: agent.name,
@@ -3283,6 +3448,7 @@ export class SimulationService {
enabled: this.#scenario.capabilities.simulatedPlayerPressure,
recentThreats: recentPlayerThreats,
},
+ ...(captureAlerts.length ? { captureAlerts } : {}),
recentMovements,
});
}
@@ -3519,7 +3685,7 @@ export class SimulationService {
[...this.#state.agents.keys()].map((id) => [id, 0]),
);
for (const hex of this.#state.hexes.values()) {
- if (hex.state === 'infected')
+ if (hex.state === 'infected' && hex.controllerAgentId !== null)
counts.set(
hex.controllerAgentId,
(counts.get(hex.controllerAgentId) ?? 0) + 1,
@@ -3638,7 +3804,9 @@ function summarizeEvent(
if (event.type === 'hex-infected') return `${name} infected ${event.cell}.`;
if (event.type === 'hex-captured') {
const previous =
- state.agents.get(event.previousControllerAgentId)?.name ??
+ (event.previousControllerAgentId === null
+ ? undefined
+ : state.agents.get(event.previousControllerAgentId)?.name) ??
'another agent';
return `${name} captured ${event.cell} from ${previous}.`;
}
diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx
index 96ea922..36026a0 100644
--- a/apps/world-lab/src/components/world-lab.test.tsx
+++ b/apps/world-lab/src/components/world-lab.test.tsx
@@ -1338,7 +1338,7 @@ describe('WorldLab', () => {
'Spawn assignment seed',
'Roster generation seed',
'Behavior assignment seed',
- 'Casual cleaner simulation seed',
+ 'Simulated player seed',
]) {
expect(screen.getByLabelText(label)).toHaveAttribute('maxlength', '80');
}
@@ -1395,11 +1395,11 @@ describe('WorldLab', () => {
await user.type(input, value);
}
await user.click(
- screen.getByRole('checkbox', { name: 'Enable casual cleaner' }),
- );
- const cleanerSeed = screen.getByLabelText(
- 'Casual cleaner simulation seed',
+ screen.getByRole('checkbox', {
+ name: 'Enable simulated player pressure',
+ }),
);
+ const cleanerSeed = screen.getByLabelText('Simulated player seed');
await user.clear(cleanerSeed);
await user.type(cleanerSeed, 'ui-cleaner-seed');
@@ -1537,7 +1537,7 @@ describe('WorldLab', () => {
expect(screen.getByLabelText('Behavior assignment seed')).toHaveValue(
'reset-behavior-seed',
);
- expect(screen.getByLabelText('Casual cleaner simulation seed')).toHaveValue(
+ expect(screen.getByLabelText('Simulated player seed')).toHaveValue(
'reset-cleaner-seed',
);
expect(
@@ -1563,7 +1563,7 @@ describe('WorldLab', () => {
await user.click(await screen.findByLabelText('More World Lab actions'));
await user.click(screen.getByRole('button', { name: 'World setup' }));
const enabled = screen.getByRole('checkbox', {
- name: 'Enable casual cleaner',
+ name: 'Enable simulated player pressure',
});
expect(enabled).not.toBeChecked();
const objective = screen.getByLabelText(
@@ -1571,7 +1571,7 @@ describe('WorldLab', () => {
);
expect(objective).toHaveValue('durable-influence-v2');
await user.click(enabled);
- const seed = screen.getByLabelText('Casual cleaner simulation seed');
+ const seed = screen.getByLabelText('Simulated player seed');
await user.clear(seed);
await user.type(seed, 'ui-pressure-a');
expect(objective).toHaveValue('durable-influence-v3');
@@ -1597,6 +1597,40 @@ describe('WorldLab', () => {
expect(await screen.findByText(/player pressure disabled/)).toBeVisible();
});
+ it('selects the seeded trail-hunter-v1 profile for a pressure experiment', async () => {
+ let previewBody: ReturnType | undefined;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input).endsWith('/setup/preview')) {
+ previewBody = JSON.parse(String(init?.body));
+ return jsonResponse(previewWorldSetup(previewBody!));
+ }
+ return jsonResponse(initial);
+ }),
+ );
+ const user = userEvent.setup();
+ render( );
+ await openOverflow(user);
+ await user.click(screen.getByRole('button', { name: 'World setup' }));
+ await user.click(
+ screen.getByRole('checkbox', {
+ name: 'Enable simulated player pressure',
+ }),
+ );
+ await user.selectOptions(
+ screen.getByLabelText('Simulated player profile'),
+ 'trail-hunter-v1',
+ );
+ await user.click(screen.getByRole('button', { name: 'Preview' }));
+
+ expect(previewBody?.simulatedPlayer).toMatchObject({
+ enabled: true,
+ profile: 'trail-hunter-v1',
+ });
+ expect(await screen.findByText(/1 seeded trail hunter/)).toBeVisible();
+ });
+
it('shows omniscient casual-cleaner position identity and activity', async () => {
const pressured = simulationSnapshotSchema.parse({
...initial,
@@ -4060,6 +4094,38 @@ describe('WorldLab', () => {
fireEvent.click(screen.getByLabelText('More World Lab actions'));
expect(screen.getByRole('button', { name: 'Reset world' })).toBeEnabled();
});
+
+ it('stops all tick controls after infection is eliminated', async () => {
+ const eliminated = simulationSnapshotSchema.parse({
+ ...initial,
+ status: 'infection-eliminated',
+ tickNumber: 1,
+ lastTickIntervalMinutes: 5,
+ resolutionOrder: [],
+ nextAgentId: null,
+ world: { ...initial.world, agents: [] },
+ resolvedModels: [],
+ behaviorConfiguration: undefined,
+ agentGoals: [],
+ agentMemories: [],
+ experiment: { ...initial.experiment, currentTerritory: [] },
+ });
+ expect(
+ simulationSnapshotSchema.safeParse(JSON.parse(JSON.stringify(eliminated)))
+ .success,
+ ).toBe(true);
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => jsonResponse(eliminated)),
+ );
+ render( );
+
+ expect(
+ await screen.findByText(/All infection has been eliminated/),
+ ).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Single tick' })).toBeDisabled();
+ });
});
function minimalExportDocument(snapshot: SimulationSnapshot) {
diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx
index e721dee..95b1fb8 100644
--- a/apps/world-lab/src/components/world-lab.tsx
+++ b/apps/world-lab/src/components/world-lab.tsx
@@ -251,6 +251,8 @@ export function WorldLab() {
if (
next.status === 'configuration-error' ||
next.status === 'budget-exhausted' ||
+ next.status === 'patient-zero-captured' ||
+ next.status === 'infection-eliminated' ||
next.world.hexes.every(({ state }) => state === 'infected') ||
(boundedRunTargetRef.current !== null &&
next.tickNumber >= boundedRunTargetRef.current)
@@ -263,7 +265,7 @@ export function WorldLab() {
setSelectedAgentId((current) =>
next.world.agents.some(({ id }) => id === current)
? current
- : next.world.agents[0]!.id,
+ : (next.world.agents[0]?.id ?? null),
);
}, []);
@@ -645,7 +647,7 @@ export function WorldLab() {
setSelectedAgentId((selected) =>
payload.snapshot.world.agents.some(({ id }) => id === selected)
? selected
- : payload.snapshot.world.agents[0]!.id,
+ : (payload.snapshot.world.agents[0]?.id ?? null),
);
} catch {
setUiError('Reset failed safely. The existing world was left intact.');
@@ -779,7 +781,9 @@ export function WorldLab() {
? 'reconciling-request'
: inFlight
? 'waiting-for-model'
- : snapshot.status === 'waiting-for-model' ||
+ : snapshot.status === 'patient-zero-captured' ||
+ snapshot.status === 'infection-eliminated' ||
+ snapshot.status === 'waiting-for-model' ||
snapshot.status === 'configuration-error' ||
snapshot.status === 'provider-error' ||
snapshot.status === 'budget-exhausted'
@@ -788,6 +792,9 @@ export function WorldLab() {
? 'running'
: 'paused';
const activeTick = snapshot.status === 'waiting-for-model';
+ const terminal =
+ snapshot.status === 'patient-zero-captured' ||
+ snapshot.status === 'infection-eliminated';
const swarmMode = snapshot.scenario.cognitionMode === 'zero-swarm-v1';
const visibleActivityTab = swarmMode ? 'swarm' : activityTab;
const zeroAgentId = snapshot.scenario.patientZeroAgentId;
@@ -1045,6 +1052,7 @@ export function WorldLab() {
activeTick ||
personalityPending ||
fullyInfected ||
+ terminal ||
!executionReady ||
snapshot.experiment.attemptAccounting.exhausted ||
snapshot.status === 'budget-exhausted'
@@ -1069,6 +1077,7 @@ export function WorldLab() {
activeTick ||
running ||
personalityPending ||
+ terminal ||
!executionReady ||
snapshot.pendingFailedTurn !== null ||
snapshot.experiment.attemptAccounting.exhausted ||
@@ -1273,17 +1282,25 @@ export function WorldLab() {
{recoveryNotice}
)}
- {(!modelsReady || uiError || fullyInfected || personalityNotice) && (
+ {(!modelsReady ||
+ uiError ||
+ fullyInfected ||
+ terminal ||
+ personalityNotice) && (
{uiError ??
(fullyInfected
? 'Development world fully infected. Automatic playback is paused; Single tick remains a manual cost-incurring diagnostic action.'
- : (personalityNotice ??
- (swarmMode
- ? 'Select an available model for Agent Zero before starting.'
- : reasoningUnavailable
- ? 'A saved reasoning profile is no longer advertised by its model. Select an available profile before starting.'
- : 'Select an available compatible model for every agent before starting.')))}
+ : terminal
+ ? snapshot.status === 'patient-zero-captured'
+ ? 'Patient Zero was captured by the simulated player. This experiment is complete; reset or apply a new World Setup to run again.'
+ : 'All infection has been eliminated. This experiment is complete; reset or apply a new World Setup to run again.'
+ : (personalityNotice ??
+ (swarmMode
+ ? 'Select an available model for Agent Zero before starting.'
+ : reasoningUnavailable
+ ? 'A saved reasoning profile is no longer advertised by its model. Select an available profile before starting.'
+ : 'Select an available compatible model for every agent before starting.')))}
)}
{!swarmMode && !snapshot.providerConfigured && (
@@ -1330,7 +1347,7 @@ export function WorldLab() {
setSelectedAgentId((selected) =>
next.world.agents.some(({ id }) => id === selected)
? selected
- : next.world.agents[0]!.id,
+ : (next.world.agents[0]?.id ?? null),
);
setExportAgentIds((selected) =>
selected.filter((id) =>
@@ -1796,7 +1813,10 @@ function HexInspector({
Controller
- {controller?.name ?? 'None'}
+
+ {controller?.name ??
+ (hex.state === 'infected' ? 'Abandoned infection' : 'None')}
+
{!swarmMode && (
<>
@@ -2566,10 +2586,30 @@ function WorldSetupPanel({
})
}
/>
- Enable casual cleaner
+ Enable simulated player pressure
+
+
+ Simulated player profile
+
+ setDraft({
+ ...draft,
+ simulatedPlayer: {
+ ...draft.simulatedPlayer,
+ profile: event.target.value as
+ 'casual-cleaner' | 'trail-hunter-v1',
+ },
+ })
+ }
+ >
+ Casual cleaner
+ Trail hunter v1
+
- Casual cleaner simulation seed
+ Simulated player seed
- Engine-owned version provenance. Enabling the casual cleaner selects
- durable-influence-v3; disabling it selects durable-influence-v2. This
- is not a seed and cannot be edited.
+ Engine-owned version provenance. Enabling simulated player pressure
+ selects durable-influence-v3; disabling it selects
+ durable-influence-v2. This is not a seed and cannot be edited.
@@ -2782,7 +2822,7 @@ function WorldSetupPanel({
{preview.scenario.areaSquareKilometers.toFixed(2)} km² ·{' '}
{preview.scenario.startingCells.length} valid spawns
{preview.scenario.simulatedPlayer.enabled
- ? ' · 1 seeded casual cleaner'
+ ? ` · 1 seeded ${preview.scenario.simulatedPlayer.profile === 'trail-hunter-v1' ? 'trail hunter' : 'casual cleaner'}`
: ' · player pressure disabled'}
{preview.scenario.setupWarnings.map((warning) => (
diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx
index a6e1d61..7363420 100644
--- a/apps/world-lab/src/components/world-map.tsx
+++ b/apps/world-lab/src/components/world-map.tsx
@@ -87,12 +87,17 @@ function asGeoJson(
state: hex.state,
controllerColor:
hex.state === 'infected'
- ? (effectiveColor(hex.controllerAgentId) ?? '#e44f45')
+ ? hex.controllerAgentId === null
+ ? '#8d8069'
+ : (effectiveColor(hex.controllerAgentId) ?? '#e44f45')
: '#4a8178',
controllerName:
hex.state === 'infected'
- ? (agentById.get(hex.controllerAgentId)?.name ?? 'Unknown agent')
+ ? hex.controllerAgentId === null
+ ? 'Abandoned infection'
+ : (agentById.get(hex.controllerAgentId)?.name ?? 'Unknown agent')
: 'Uncontrolled',
+ abandoned: hex.state === 'infected' && hex.controllerAgentId === null,
selected: hex.cell === selectedCell,
},
geometry: {
@@ -445,11 +450,15 @@ export function WorldMap(props: WorldMapProps) {
markersRef.current.push(marker);
}
if (simulatedPlayer) {
+ const playerName =
+ simulatedPlayer.profile === 'trail-hunter-v1'
+ ? 'Trail hunter'
+ : 'Casual cleaner';
const element = document.createElement('div');
element.className = 'simulated-player-marker';
element.setAttribute('role', 'img');
- element.setAttribute('aria-label', 'Casual cleaner simulated player');
- element.title = `Casual cleaner · ${simulatedPlayer.currentCell}`;
+ element.setAttribute('aria-label', `${playerName} simulated player`);
+ element.title = `${playerName} · ${simulatedPlayer.currentCell}`;
element.textContent = 'P';
const [lat, lng] = cellToLatLng(simulatedPlayer.currentCell);
markersRef.current.push(
@@ -485,7 +494,8 @@ export function WorldMap(props: WorldMapProps) {
}
data-controller-colors={hexes
.flatMap((hex) => {
- if (hex.state === 'open') return [];
+ if (hex.state === 'open' || hex.controllerAgentId === null)
+ return [];
const effectiveColor = resolveAgentColor(
{ world: { agents, alliances } } as unknown as Pick<
SimulationSnapshot,
@@ -508,7 +518,11 @@ export function WorldMap(props: WorldMapProps) {
{simulatedPlayer && (
{' '}
- · Cleaner {simulatedPlayer.metrics.movements} moved ·{' '}
+ ·{' '}
+ {simulatedPlayer.profile === 'trail-hunter-v1'
+ ? 'Trail hunter'
+ : 'Cleaner'}{' '}
+ {simulatedPlayer.metrics.movements} moved ·{' '}
{simulatedPlayer.metrics.cellsDisinfected} cleaned ·{' '}
{simulatedPlayer.metrics.blockedDisinfections} blocked
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index eb16798..98ec985 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -33,12 +33,15 @@ they do not enforce the upstream account balance. See ADRs 0025 and 0026.
## Simultaneous tick authority
-Before the frozen agent snapshot, the optional seeded `casual-cleaner` advances
-one deterministic virtual interval in the world engine. Its movement and
-disinfection/block events remain an uncommitted candidate until the complete
-agent tick commits, so cancellation cannot partially advance player pressure.
-The engine targets visible infection rather than hidden agent positions;
-positions are consulted only for authoritative co-located clean blocking.
+Before the frozen agent snapshot, the optional seeded simulated-player profile
+advances one deterministic virtual interval in the world engine. The baseline
+`casual-cleaner` retains its movement and blocked-clean behavior. The optional
+`trail-hunter-v1` routes from visible infection and can capture an agent only
+when they share a cell. Capture removes the agent, leaves its infected cells
+abandoned, and changes the active roster before provider dispatch. If no agents
+remain, or Patient Zero is captured in zero-swarm mode, the player-only tick
+commits a terminal outcome. Player events remain an uncommitted candidate until
+the tick commits, so cancellation cannot partially advance pressure.
Ordinary agents retain bounded own/nearby successful-clean evidence. The one
configured Patient Zero additionally receives a deterministic, current-interval
feed of successful cleans and occupied-cell blocks, capped at 128 entries with
diff --git a/docs/GAMEPLAY_FOUNDATION.md b/docs/GAMEPLAY_FOUNDATION.md
index f7fe542..2eb639d 100644
--- a/docs/GAMEPLAY_FOUNDATION.md
+++ b/docs/GAMEPLAY_FOUNDATION.md
@@ -255,6 +255,13 @@ adjacent H3 cell per explicit interval toward visible infection, uses its seed
for stable tie-breaking, and attempts at most one disinfection. An occupied
infected cell blocks cleaning. Other profiles and capture remain deferred.
+The later `trail-hunter-v1` experiment adds a separate optional profile. It
+routes from visible infected cells without using hidden agent positions as
+targets. Co-location can capture an agent, immediately remove it, and leave its
+territory infected but abandoned. If this removes the last agent, or removes
+Patient Zero from a zero-swarm experiment, the run records a terminal outcome.
+Replacement spawning remains a later population-maintenance milestone.
+
Slice D1.1 adds no cleaner mechanics. The single Patient Zero receives a
current-interval-only global feed of authoritative successful disinfections and
occupied-cell blocked-clean encounters. Entries identify the affected or
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 02f4113..27bcfe3 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -4,6 +4,11 @@ Simulated-player evidence is engine-authored. Agent observations may contain
bounded recent disinfection evidence but never the cleaner's live cell, route,
or future timing. World Lab is intentionally omniscient and may show those
details. Agent-authored text cannot create or modify player activity.
+The optional trail hunter selects routes from visible infected cells only.
+The engine checks co-location for capture after movement; it does not use hidden
+agent positions as targets. Capture events and abandoned controllers are
+authoritative state and are safe to export without exposing player routing
+internals to agent providers.
Patient Zero alone additionally receives a bounded current-interval feed of
successful disinfections and occupied-cell blocks. Its named agent/alliance
attribution is engine-authored; it contains no movement events, player ID,
diff --git a/docs/TESTING.md b/docs/TESTING.md
index f1bb18b..a561f65 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -17,6 +17,10 @@ PR D covers the same-request Jev Choice and Noul response, deterministic
replan signals, periodic and event-triggered Zero calls, directive expiry,
current legal Zero actions on reuse ticks, provider-attempt reduction,
cancellation rollback, and mode-specific World Lab telemetry.
+PR E covers seeded trail-hunter routing from visible infection, co-located
+capture, abandoned territory, active-roster dispatch after player pressure,
+terminal capture outcomes, cancellation rollback, and safe export/UI handling.
+The casual-cleaner baseline remains covered by its existing offline tests.
Attempt-budget tests use deterministic providers and cover whole-roster tick
admission, retry permits, cancellation finalization, and the distinction
diff --git a/docs/adr/0032-trail-hunter-pressure.md b/docs/adr/0032-trail-hunter-pressure.md
new file mode 100644
index 0000000..6b11603
--- /dev/null
+++ b/docs/adr/0032-trail-hunter-pressure.md
@@ -0,0 +1,32 @@
+# ADR 0032: Optional trail hunter pressure
+
+## Status
+
+Accepted for zero-swarm comparison experiments.
+
+## Decision
+
+World scenarios may opt into seeded `trail-hunter-v1` pressure while retaining
+`casual-cleaner` for baseline comparisons. The world engine advances one
+adjacent player step per interval, ranking infected cells as visible trail
+evidence with deterministic seed ties. It does not route toward hidden agent
+positions. On co-location, the engine can capture one agent. The captured
+agent is removed immediately and its infected cells become abandoned. A
+surviving agent can capture abandoned territory using the existing legal world
+action.
+
+The service selects active agents after the candidate player advance. Captured
+agents receive no provider call or world action. The candidate commits with
+the complete tick, and cancellation rolls it back. If the last agent is
+captured, or Patient Zero is captured in zero-swarm mode, a player-only tick
+commits a terminal status without dispatching providers.
+
+Safe snapshots and exports retain the profile, movement/disinfection/capture
+events, abandoned control, and terminal status. They do not expose provider
+secrets. Replacements and capture cooldowns are separate work.
+
+## Consequences
+
+This pressure profile creates real survival outcomes in deterministic
+experiments. Its search policy is intentionally simple; comparisons must not
+claim that it models an optimal human player.
diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts
index 3209e60..49d70ec 100644
--- a/packages/shared/src/index.test.ts
+++ b/packages/shared/src/index.test.ts
@@ -21,6 +21,7 @@ import {
experimentExportWorldStateSchema,
experimentExportTurnSchema,
hexCapturedWorldEventSchema,
+ simulatedPlayerAgentCapturedEventSchema,
hexSchema,
invalidActionReasonSchema,
experimentExportRequestSchema,
@@ -938,7 +939,7 @@ describe('agent observation and decision schemas', () => {
expect(
hexSchema.safeParse({ cell, state: 'infected', controllerAgentId: null })
.success,
- ).toBe(false);
+ ).toBe(true);
expect(
hexCapturedWorldEventSchema.safeParse({
id: '67aa21b9-fc78-4b04-9f92-9862bf346f96',
@@ -950,6 +951,29 @@ describe('agent observation and decision schemas', () => {
occurredAt: '2026-08-13T12:00:01.000Z',
}).success,
).toBe(true);
+ expect(
+ hexCapturedWorldEventSchema.safeParse({
+ id: '67aa21b9-fc78-4b04-9f92-9862bf346f96',
+ type: 'hex-captured',
+ agentId,
+ controllerAgentId: agentId,
+ previousControllerAgentId: null,
+ cell,
+ occurredAt: '2026-08-13T12:00:01.000Z',
+ }).success,
+ ).toBe(true);
+ expect(
+ simulatedPlayerAgentCapturedEventSchema.safeParse({
+ id: '67aa21b9-fc78-4b04-9f92-9862bf346f96',
+ type: 'simulated-player-agent-captured',
+ profile: 'trail-hunter-v1',
+ originatingTick: 2,
+ occurredAt: '2026-08-13T12:00:01.000Z',
+ cell,
+ capturedAgentId: agentId,
+ abandonedCellCount: 3,
+ }).success,
+ ).toBe(true);
expect(invalidActionReasonSchema.parse('capture-open-cell')).toBe(
'capture-open-cell',
);
@@ -1313,6 +1337,47 @@ describe('turn and snapshot schemas', () => {
).toBe(false);
});
+ it('accepts terminal player-only ticks after trail-hunter captures', () => {
+ const terminalBase = {
+ ...snapshot,
+ tickNumber: 1,
+ lastTickIntervalMinutes: 5,
+ resolutionOrder: [],
+ activeAgentId: null,
+ turns: [],
+ };
+ expect(
+ simulationSnapshotSchema.safeParse({
+ ...terminalBase,
+ status: 'infection-eliminated',
+ nextAgentId: null,
+ world: { ...snapshot.world, agents: [] },
+ resolvedModels: [],
+ experiment: { ...snapshot.experiment, currentTerritory: [] },
+ }).success,
+ ).toBe(true);
+
+ const survivingAgents = snapshot.world.agents.slice(1);
+ const survivingIds = new Set(survivingAgents.map(({ id }) => id));
+ expect(
+ simulationSnapshotSchema.safeParse({
+ ...terminalBase,
+ status: 'patient-zero-captured',
+ nextAgentId: survivingAgents[0]!.id,
+ world: { ...snapshot.world, agents: survivingAgents },
+ resolvedModels: snapshot.resolvedModels.filter(({ agentId }) =>
+ survivingIds.has(agentId),
+ ),
+ experiment: {
+ ...snapshot.experiment,
+ currentTerritory: snapshot.experiment.currentTerritory.filter(
+ ({ agentId }) => survivingIds.has(agentId),
+ ),
+ },
+ }).success,
+ ).toBe(true);
+ });
+
it('requires tick attribution as one complete metadata group', () => {
const lost = {
...baseTurn,
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 13cee43..2c8a104 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -95,7 +95,10 @@ export const h3CellSchema = z
.brand<'H3Cell'>();
export type H3Cell = z.infer;
-export const simulatedPlayerProfileSchema = z.literal('casual-cleaner');
+export const simulatedPlayerProfileSchema = z.enum([
+ 'casual-cleaner',
+ 'trail-hunter-v1',
+]);
export type SimulatedPlayerProfile = z.infer<
typeof simulatedPlayerProfileSchema
>;
@@ -140,7 +143,7 @@ export const hexSchema = z.discriminatedUnion('state', [
z.object({
cell: h3CellSchema,
state: z.literal('infected'),
- controllerAgentId: agentIdSchema,
+ controllerAgentId: agentIdSchema.nullable(),
}),
]);
export type Hex = z.infer;
@@ -562,6 +565,16 @@ const reflexCandidateSchema = z
})
.strict();
+export const captureAlertSchema = z
+ .object({
+ capturedAgentId: agentIdSchema,
+ cell: h3CellSchema,
+ originatingTick: z.number().int().nonnegative(),
+ abandonedCellCount: z.number().int().nonnegative(),
+ })
+ .strict();
+export type CaptureAlert = z.infer;
+
export const reflexObservationSchema = z
.object({
agentId: agentIdSchema,
@@ -576,6 +589,7 @@ export const reflexObservationSchema = z
})
.strict(),
relevantRecentFacts: z.array(z.string().trim().min(1).max(160)).max(6),
+ captureAlerts: z.array(captureAlertSchema).max(4).optional(),
candidates: z.array(reflexCandidateSchema).min(1).max(9),
})
.strict()
@@ -648,6 +662,7 @@ export const swarmReplanReasonSchema = z.enum([
'territory-loss',
'high-pressure',
'player-disinfection',
+ 'roster-changed',
]);
export type SwarmReplanReason = z.infer;
@@ -828,7 +843,7 @@ export const hexCapturedWorldEventSchema = worldEventBaseSchema.extend({
type: z.literal('hex-captured'),
cell: h3CellSchema,
controllerAgentId: agentIdSchema,
- previousControllerAgentId: agentIdSchema,
+ previousControllerAgentId: agentIdSchema.nullable(),
});
export type HexCapturedWorldEvent = z.infer;
const agentWaitedWorldEventSchema = worldEventBaseSchema.extend({
@@ -851,7 +866,7 @@ export const hexDisinfectedWorldEventSchema =
simulatedPlayerEventBaseSchema.extend({
type: z.literal('hex-disinfected'),
cell: h3CellSchema,
- previousControllerAgentId: agentIdSchema,
+ previousControllerAgentId: agentIdSchema.nullable(),
});
export const simulatedPlayerCleanBlockedEventSchema =
simulatedPlayerEventBaseSchema.extend({
@@ -859,10 +874,18 @@ export const simulatedPlayerCleanBlockedEventSchema =
cell: h3CellSchema,
blockingAgentId: agentIdSchema,
});
+export const simulatedPlayerAgentCapturedEventSchema =
+ simulatedPlayerEventBaseSchema.extend({
+ type: z.literal('simulated-player-agent-captured'),
+ cell: h3CellSchema,
+ capturedAgentId: agentIdSchema,
+ abandonedCellCount: z.number().int().nonnegative(),
+ });
export const simulatedPlayerEventSchema = z.discriminatedUnion('type', [
simulatedPlayerMovedEventSchema,
hexDisinfectedWorldEventSchema,
simulatedPlayerCleanBlockedEventSchema,
+ simulatedPlayerAgentCapturedEventSchema,
]);
export type SimulatedPlayerEvent = z.infer;
@@ -946,6 +969,7 @@ export const safeExperimentWorldEventSchema = z.discriminatedUnion('type', [
simulatedPlayerMovedEventSchema,
hexDisinfectedWorldEventSchema,
simulatedPlayerCleanBlockedEventSchema,
+ simulatedPlayerAgentCapturedEventSchema,
]);
export type SafeExperimentWorldEvent = z.infer<
typeof safeExperimentWorldEventSchema
@@ -969,6 +993,7 @@ export const worldEventSchema = z.discriminatedUnion('type', [
simulatedPlayerMovedEventSchema,
hexDisinfectedWorldEventSchema,
simulatedPlayerCleanBlockedEventSchema,
+ simulatedPlayerAgentCapturedEventSchema,
]);
export type WorldEvent = z.infer;
@@ -1118,10 +1143,7 @@ const worldSnapshotObjectSchema = z.object({
.array(hexSchema)
.min(1)
.max(WORLD_SCENARIO_LIMITS.maximumGeneratedCells),
- agents: z
- .array(agentSchema)
- .min(WORLD_SCENARIO_LIMITS.minimumAgents)
- .max(WORLD_SCENARIO_LIMITS.maximumAgents),
+ agents: z.array(agentSchema).min(0).max(WORLD_SCENARIO_LIMITS.maximumAgents),
events: z.array(worldEventSchema).max(120),
alliances: z
.array(allianceSchema)
@@ -1156,7 +1178,11 @@ function validateWorldControllers(
message: 'The simulated player must remain inside the world.',
});
for (const [index, hex] of world.hexes.entries()) {
- if (hex.state === 'infected' && !agentIds.has(hex.controllerAgentId))
+ if (
+ hex.state === 'infected' &&
+ hex.controllerAgentId !== null &&
+ !agentIds.has(hex.controllerAgentId)
+ )
context.addIssue({
code: 'custom',
path: ['hexes', index, 'controllerAgentId'],
@@ -1274,9 +1300,9 @@ export const cellObservationSchema = z.discriminatedUnion('state', [
z.object({
cell: h3CellSchema,
state: z.literal('infected'),
- controllerAgentId: agentIdSchema,
+ controllerAgentId: agentIdSchema.nullable(),
controllerAllianceId: allianceIdSchema.nullable(),
- effectiveColor: colorSchema,
+ effectiveColor: colorSchema.nullable(),
}),
]);
export type CellObservation = z.infer;
@@ -1356,7 +1382,7 @@ export const territoryScoreboardEntrySchema = z.object({
});
export const territoryScoreboardSchema = z
.array(territoryScoreboardEntrySchema)
- .min(WORLD_SCENARIO_LIMITS.minimumAgents)
+ .min(0)
.max(WORLD_SCENARIO_LIMITS.maximumAgents)
.refine(
(entries) =>
@@ -2074,6 +2100,7 @@ const agentObservationObjectSchema = z.object({
})
.strict()
.default({ enabled: false, recentThreats: [] }),
+ captureAlerts: z.array(captureAlertSchema).max(4).optional(),
recentMovements: z
.array(
z.object({
@@ -3300,6 +3327,8 @@ export const simulationStatusSchema = z.enum([
'configuration-error',
'provider-error',
'budget-exhausted',
+ 'patient-zero-captured',
+ 'infection-eliminated',
]);
export type SimulationStatus = z.infer;
@@ -3407,7 +3436,7 @@ export const simulationSnapshotSchema = z
.nullable()
.default(null),
resolutionOrder: z.array(agentIdSchema).default([]),
- nextAgentId: agentIdSchema,
+ nextAgentId: agentIdSchema.nullable(),
activeAgentId: agentIdSchema.nullable(),
cancellationRequested: z.boolean().default(false),
pendingFailedTurn: z
@@ -3433,10 +3462,16 @@ export const simulationSnapshotSchema = z
.strict()
.optional(),
modelConfiguration: experimentModelConfigurationSchema,
- behaviorConfiguration: behaviorConfigurationSchema.optional(),
+ behaviorConfiguration: behaviorConfigurationSchema
+ .safeExtend({
+ assignments: z
+ .array(behaviorAssignmentSchema)
+ .max(WORLD_SCENARIO_LIMITS.maximumAgents),
+ })
+ .optional(),
resolvedModels: z
.array(resolvedAgentModelSchema)
- .min(WORLD_SCENARIO_LIMITS.minimumAgents)
+ .min(0)
.max(WORLD_SCENARIO_LIMITS.maximumAgents),
agentGoals: z
.array(
@@ -3483,6 +3518,26 @@ export const simulationSnapshotSchema = z
})
.superRefine((snapshot, context) => {
const rosterIds = new Set(snapshot.world.agents.map(({ id }) => id));
+ const terminal =
+ snapshot.status === 'patient-zero-captured' ||
+ snapshot.status === 'infection-eliminated';
+ if (
+ (rosterIds.size === 0 && snapshot.nextAgentId !== null) ||
+ (rosterIds.size > 0 &&
+ (snapshot.nextAgentId === null || !rosterIds.has(snapshot.nextAgentId)))
+ )
+ context.addIssue({
+ code: 'custom',
+ path: ['nextAgentId'],
+ message:
+ 'Next agent must identify an active agent, or be null when none remain.',
+ });
+ if (rosterIds.size === 0 && snapshot.status !== 'infection-eliminated')
+ context.addIssue({
+ code: 'custom',
+ path: ['status'],
+ message: 'A world without active agents has an eliminated infection.',
+ });
if (
snapshot.scenario.simulatedPlayer.enabled !==
Boolean(snapshot.world.simulatedPlayer) ||
@@ -3523,10 +3578,22 @@ export const simulationSnapshotSchema = z
'An unstarted simulation cannot have tick resolution metadata.',
});
} else if (
- snapshot.resolutionOrder.length !== rosterIds.size ||
- new Set(snapshot.resolutionOrder).size !== rosterIds.size ||
- snapshot.resolutionOrder.some((id) => !rosterIds.has(id)) ||
- snapshot.lastTickIntervalMinutes === null
+ terminal &&
+ (snapshot.resolutionOrder.length !== 0 ||
+ snapshot.lastTickIntervalMinutes === null)
+ )
+ context.addIssue({
+ code: 'custom',
+ path: ['resolutionOrder'],
+ message:
+ 'A terminal player-only tick must not resolve agent actions and requires an interval.',
+ });
+ else if (
+ !terminal &&
+ (snapshot.resolutionOrder.length !== rosterIds.size ||
+ new Set(snapshot.resolutionOrder).size !== rosterIds.size ||
+ snapshot.resolutionOrder.some((id) => !rosterIds.has(id)) ||
+ snapshot.lastTickIntervalMinutes === null)
)
context.addIssue({
code: 'custom',
@@ -3534,6 +3601,24 @@ export const simulationSnapshotSchema = z
message:
'A committed tick requires one ordered entry per active agent and an interval.',
});
+ if (
+ snapshot.status === 'infection-eliminated' &&
+ snapshot.world.agents.length !== 0
+ )
+ context.addIssue({
+ code: 'custom',
+ path: ['world', 'agents'],
+ message: 'An eliminated infection cannot retain active agents.',
+ });
+ if (
+ snapshot.status === 'patient-zero-captured' &&
+ rosterIds.has(snapshot.scenario.patientZeroAgentId)
+ )
+ context.addIssue({
+ code: 'custom',
+ path: ['world', 'agents'],
+ message: 'Patient Zero cannot remain active after capture.',
+ });
if (
snapshot.experiment.currentTerritory.length !== rosterIds.size ||
snapshot.experiment.currentTerritory.some(
@@ -3598,7 +3683,7 @@ export const simulationSnapshotSchema = z
snapshot.world.agents.map(({ id }) => [id, 0]),
);
for (const hex of snapshot.world.hexes) {
- if (hex.state === 'infected')
+ if (hex.state === 'infected' && hex.controllerAgentId !== null)
authoritative.set(
hex.controllerAgentId,
(authoritative.get(hex.controllerAgentId) ?? 0) + 1,
diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts
index d6bb500..670161a 100644
--- a/packages/world-engine/src/index.test.ts
+++ b/packages/world-engine/src/index.test.ts
@@ -12,6 +12,8 @@ import {
applyDiplomacy,
applyWorldAction,
advanceCasualCleaner,
+ advanceSimulatedPlayer,
+ advanceTrailHunter,
areAdjacent,
createDevelopmentWorld,
deterministicAllianceColor,
@@ -137,6 +139,126 @@ describe('D1 casual cleaner authority', () => {
});
});
+describe('trail hunter deterministic pressure', () => {
+ it('routes from infection evidence with stable seeded tie-breaking', () => {
+ const before = stateWithAgent();
+ const target = h3CellSchema.parse(
+ gridDisk(center, 2).find((cell) => gridDistance(center, cell) === 2),
+ );
+ const pressured = {
+ ...before,
+ hexes: new Map(before.hexes).set(target, {
+ state: 'infected' as const,
+ controllerAgentId: agentId,
+ }),
+ agents: new Map(),
+ simulatedPlayer: {
+ profile: 'trail-hunter-v1' as const,
+ currentCell: center,
+ metrics: { movements: 0, cellsDisinfected: 0, blockedDisinfections: 0 },
+ },
+ };
+
+ const first = advanceTrailHunter(pressured, 'hunter-route', 1, context);
+ const second = advanceTrailHunter(pressured, 'hunter-route', 1, context);
+
+ expect(first).toEqual(second);
+ expect(first.events).toMatchObject([{ type: 'simulated-player-moved' }]);
+ expect(gridDistance(center, first.state.simulatedPlayer!.currentCell)).toBe(
+ 1,
+ );
+ });
+
+ it('does not route toward hidden agent positions', () => {
+ const before = stateWithAgent();
+ const target = h3CellSchema.parse(
+ gridDisk(center, 2).find((cell) => gridDistance(center, cell) === 2),
+ );
+ const shared = {
+ ...before,
+ hexes: new Map(before.hexes).set(target, {
+ state: 'infected' as const,
+ controllerAgentId: agentId,
+ }),
+ simulatedPlayer: {
+ profile: 'trail-hunter-v1' as const,
+ currentCell: center,
+ metrics: { movements: 0, cellsDisinfected: 0, blockedDisinfections: 0 },
+ },
+ };
+ const withoutOtherAgent = { ...shared, agents: new Map() };
+ const withDistantAgent = {
+ ...shared,
+ agents: new Map([
+ [recipientId, { ...agent, id: recipientId, currentCell: distant }],
+ ]),
+ };
+
+ const without = advanceTrailHunter(
+ withoutOtherAgent,
+ 'hunter-evidence',
+ 2,
+ context,
+ );
+ const withAgent = advanceTrailHunter(
+ withDistantAgent,
+ 'hunter-evidence',
+ 2,
+ context,
+ );
+
+ expect(withAgent.events).toEqual(without.events);
+ expect(withAgent.state.simulatedPlayer).toEqual(
+ without.state.simulatedPlayer,
+ );
+ });
+
+ it('captures a co-located agent and abandons its infected territory', () => {
+ const before = stateWithAgent();
+ const pressured = {
+ ...before,
+ hexes: new Map(before.hexes)
+ .set(center, {
+ state: 'infected' as const,
+ controllerAgentId: agentId,
+ })
+ .set(adjacent, {
+ state: 'infected' as const,
+ controllerAgentId: agentId,
+ }),
+ simulatedPlayer: {
+ profile: 'trail-hunter-v1' as const,
+ currentCell: center,
+ metrics: { movements: 0, cellsDisinfected: 0, blockedDisinfections: 0 },
+ },
+ };
+
+ const result = advanceSimulatedPlayer(
+ pressured,
+ 'hunter-capture',
+ 3,
+ context,
+ );
+
+ expect(result.state.agents.has(agentId)).toBe(false);
+ expect(result.state.hexes.get(center)).toEqual({
+ state: 'infected',
+ controllerAgentId: null,
+ });
+ expect(result.state.hexes.get(adjacent)).toEqual({
+ state: 'infected',
+ controllerAgentId: null,
+ });
+ expect(result.events).toMatchObject([
+ {
+ type: 'simulated-player-agent-captured',
+ capturedAgentId: agentId,
+ abandonedCellCount: 2,
+ },
+ ]);
+ });
+});
+
function stateWithAgent() {
const base = toWorldState(
createDevelopmentWorld({ generatedAt: '2026-08-13T12:00:00.000Z' }),
diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts
index 118af4d..7d5eafb 100644
--- a/packages/world-engine/src/index.ts
+++ b/packages/world-engine/src/index.ts
@@ -196,9 +196,196 @@ export function advanceCasualCleaner(
return { state: nextState, events };
}
+/**
+ * Advance the stronger deterministic pressure profile for one virtual
+ * interval. The hunter routes only from visible infected cells, which are the
+ * observable infection trail. It deliberately never ranks or routes toward
+ * agent positions. Agent positions are consulted only once the player shares
+ * a cell, where the engine authoritatively resolves a capture.
+ */
+export function advanceTrailHunter(
+ state: WorldState,
+ seed: string,
+ tickNumber: number,
+ context: Pick,
+): AdvancedSimulatedPlayer {
+ const player = state.simulatedPlayer;
+ if (!player) return { state, events: [] };
+
+ let currentCell = player.currentCell;
+ const infected = [...state.hexes]
+ .filter(
+ (entry): entry is [H3Cell, Extract] =>
+ entry[1].state === 'infected',
+ )
+ .map(([cell]) => cell);
+ const events: SimulatedPlayerEvent[] = [];
+ const eventBase = () => ({
+ id: context.createEventId() as SimulatedPlayerEvent['id'],
+ occurredAt: context.now(),
+ profile: 'trail-hunter-v1' as const,
+ originatingTick: tickNumber,
+ });
+
+ if (infected.length && state.hexes.get(currentCell)?.state !== 'infected') {
+ const target = infected
+ .map((cell) => ({
+ cell,
+ distance:
+ safeGridDistance(currentCell, cell) ?? Number.MAX_SAFE_INTEGER,
+ rank: seededNumber(
+ `${seed}:trail-hunter:target:${tickNumber}:${cell}`,
+ )(),
+ }))
+ .sort(
+ (a, b) =>
+ a.distance - b.distance ||
+ a.rank - b.rank ||
+ a.cell.localeCompare(b.cell),
+ )[0]?.cell;
+ if (target) {
+ const currentDistance =
+ safeGridDistance(currentCell, target) ?? Number.MAX_SAFE_INTEGER;
+ const next = gridDisk(currentCell, 1)
+ .filter(
+ (cell) => cell !== currentCell && state.hexes.has(cell as H3Cell),
+ )
+ .map((cell) => ({
+ cell: h3CellSchema.parse(cell),
+ distance:
+ safeGridDistance(h3CellSchema.parse(cell), target) ??
+ Number.MAX_SAFE_INTEGER,
+ rank: seededNumber(
+ `${seed}:trail-hunter:step:${tickNumber}:${cell}`,
+ )(),
+ }))
+ .sort(
+ (a, b) =>
+ a.distance - b.distance ||
+ a.rank - b.rank ||
+ a.cell.localeCompare(b.cell),
+ )[0];
+ if (next && next.distance < currentDistance) {
+ events.push({
+ ...eventBase(),
+ type: 'simulated-player-moved',
+ fromCell: currentCell,
+ toCell: next.cell,
+ });
+ currentCell = next.cell;
+ }
+ }
+ }
+
+ let hexes = state.hexes;
+ let agents = state.agents;
+ let alliances = state.alliances;
+ let pendingAllianceProposals = state.pendingAllianceProposals;
+ let metrics = {
+ ...player.metrics,
+ movements:
+ player.metrics.movements +
+ events.filter(({ type }) => type === 'simulated-player-moved').length,
+ };
+ const captured = [...state.agents.values()]
+ .filter(({ currentCell: agentCell }) => agentCell === currentCell)
+ .sort((a, b) => a.id.localeCompare(b.id))[0];
+ if (captured) {
+ agents = new Map(state.agents);
+ (agents as Map).delete(captured.id);
+ const capturedAllianceIds = new Set();
+ alliances = new Map(
+ [...(state.alliances?.entries() ?? [])].flatMap(([id, alliance]) => {
+ if (!alliance.memberAgentIds.includes(captured.id))
+ return [[id, alliance] as const];
+ const survivors = alliance.memberAgentIds.filter(
+ (memberId) => memberId !== captured.id,
+ );
+ if (survivors.length < 2) {
+ capturedAllianceIds.add(id);
+ return [];
+ }
+ return [[id, { ...alliance, memberAgentIds: survivors }] as const];
+ }),
+ );
+ pendingAllianceProposals = new Map(
+ [...(state.pendingAllianceProposals?.entries() ?? [])].filter(
+ ([, proposal]) =>
+ proposal.proposerAgentId !== captured.id &&
+ proposal.recipientAgentId !== captured.id &&
+ (proposal.proposerAllianceId === null ||
+ !capturedAllianceIds.has(proposal.proposerAllianceId)) &&
+ (proposal.recipientAllianceId === null ||
+ !capturedAllianceIds.has(proposal.recipientAllianceId)),
+ ),
+ );
+ const abandonedCells = [...state.hexes].filter(
+ ([, hex]) =>
+ hex.state === 'infected' && hex.controllerAgentId === captured.id,
+ );
+ if (abandonedCells.length) {
+ hexes = new Map(state.hexes);
+ for (const [cell, hex] of abandonedCells)
+ (hexes as Map).set(cell, {
+ ...hex,
+ controllerAgentId: null,
+ });
+ }
+ events.push({
+ ...eventBase(),
+ type: 'simulated-player-agent-captured',
+ cell: currentCell,
+ capturedAgentId: captured.id,
+ abandonedCellCount: abandonedCells.length,
+ });
+ } else {
+ const current = state.hexes.get(currentCell);
+ if (current?.state === 'infected') {
+ events.push({
+ ...eventBase(),
+ type: 'hex-disinfected',
+ cell: currentCell,
+ previousControllerAgentId: current.controllerAgentId,
+ });
+ hexes = new Map(state.hexes);
+ (hexes as Map).set(currentCell, {
+ state: 'open',
+ controllerAgentId: null,
+ });
+ metrics = {
+ ...metrics,
+ cellsDisinfected: metrics.cellsDisinfected + 1,
+ };
+ }
+ }
+
+ const nextState: WorldState = {
+ ...state,
+ hexes,
+ agents,
+ alliances,
+ pendingAllianceProposals,
+ simulatedPlayer: { ...player, currentCell, metrics },
+ events: [...state.events, ...events],
+ };
+ return { state: nextState, events };
+}
+
+/** Dispatches the configured deterministic simulated-player profile. */
+export function advanceSimulatedPlayer(
+ state: WorldState,
+ seed: string,
+ tickNumber: number,
+ context: Pick,
+): AdvancedSimulatedPlayer {
+ if (state.simulatedPlayer?.profile === 'trail-hunter-v1')
+ return advanceTrailHunter(state, seed, tickNumber, context);
+ return advanceCasualCleaner(state, seed, tickNumber, context);
+}
+
export type HexControl =
| { readonly state: 'open'; readonly controllerAgentId: null }
- | { readonly state: 'infected'; readonly controllerAgentId: AgentId };
+ | { readonly state: 'infected'; readonly controllerAgentId: AgentId | null };
export interface EngineContext {
createEventId: () => string;
@@ -314,13 +501,14 @@ export function getCaptureEligibility(
if (currentHex.controllerAgentId === agentId)
return { eligible: false, blockedReason: 'already-controller' };
const actingAlliance = getAgentAlliance(state, agentId);
- const controllerAlliance = getAgentAlliance(
- state,
- currentHex.controllerAgentId,
- );
+ const controllerAlliance = currentHex.controllerAgentId
+ ? getAgentAlliance(state, currentHex.controllerAgentId)
+ : undefined;
if (actingAlliance && controllerAlliance?.id === actingAlliance.id)
return { eligible: false, blockedReason: 'allied-controller' };
- const controller = state.agents.get(currentHex.controllerAgentId);
+ const controller = currentHex.controllerAgentId
+ ? state.agents.get(currentHex.controllerAgentId)
+ : undefined;
if (controller?.currentCell === agent.currentCell)
return { eligible: false, blockedReason: 'controller-present' };
return { eligible: true };