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 + +