From 4d3060718ff70074e37adfdcf8b37c42231afc94 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 22:44:10 -0400 Subject: [PATCH 1/2] feat(swarm): improve trail-hunter threat awareness --- apps/game-api/src/reflex-execution.test.ts | 25 +- apps/game-api/src/reflex-execution.ts | 22 +- .../src/simulation-service.swarm.test.ts | 218 ++++++++++++++++++ apps/game-api/src/simulation-service.ts | 70 ++++-- apps/game-api/src/swarm-pressure.test.ts | 73 ++++++ apps/game-api/src/swarm-pressure.ts | 145 ++++++++++++ docs/ARCHITECTURE.md | 9 + docs/SECURITY.md | 4 + docs/TESTING.md | 3 + .../agent-runtime/src/swarm-planner.test.ts | 66 ++++++ packages/agent-runtime/src/swarm-planner.ts | 6 +- packages/shared/src/index.test.ts | 64 +++++ packages/shared/src/index.ts | 28 +++ 13 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 apps/game-api/src/swarm-pressure.test.ts create mode 100644 apps/game-api/src/swarm-pressure.ts diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index fe576ee..597dcbb 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -6,7 +6,11 @@ import { TypeSafeJevReflexProvider, type ReflexProvider, } from '@hexzero/agent-runtime'; -import { type SwarmDirective } from '@hexzero/shared'; +import { + simulatedPlayerEventSchema, + type SimulatedPlayerEvent, + type SwarmDirective, +} from '@hexzero/shared'; import { applyWorldAction, createDevelopmentWorld, @@ -118,6 +122,25 @@ describe('zero-swarm reflex execution seam', () => { expect(compiled.observation).not.toHaveProperty('simulatedPlayer'); }); + it('uses a current public disinfection for immediate pressure without exposing player state', () => { + const { state, directive, targetCell } = fixture(); + const event = simulatedPlayerEventSchema.parse({ + id: '00000000-0000-4000-8000-000000000020', + occurredAt: '2026-08-13T12:00:00.000Z', + profile: 'trail-hunter-v1', + originatingTick: 1, + type: 'hex-disinfected', + cell: targetCell, + previousControllerAgentId: null, + }) as Extract; + const compiled = compileReflexObservation(state, directive, { + pressureEvents: [event], + }); + expect(compiled.observation.currentSituation.nearbyPressure).toBe('high'); + expect(compiled.observation).not.toHaveProperty('simulatedPlayer'); + expect(compiled.observation).not.toHaveProperty('pressureEvents'); + }); + 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 47511ed..1400870 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -10,6 +10,7 @@ import { swarmDirectiveSchema, type H3Cell, type CaptureAlert, + type SimulatedPlayerEvent, type ProviderFailure, type ProviderMetadata, type ReflexDecision, @@ -23,10 +24,16 @@ import { } from '@hexzero/world-engine'; import { AttemptAccounting } from './attempt-accounting'; import { geographicDirectionBetweenCells } from './geographic-direction'; +import { localPressureAtCell, localPressureFromCells } from './swarm-pressure'; export interface ReflexLocalHistory { previousCell?: H3Cell; recentCleanedCells?: readonly H3Cell[]; + /** Public disinfection effects, already bounded by the tick executor. */ + pressureEvents?: readonly Extract< + SimulatedPlayerEvent, + { type: 'hex-disinfected' } + >[]; territoryDelta?: number; recentActionOutcome?: 'success' | 'rejected' | 'unknown'; captureAlerts?: readonly CaptureAlert[]; @@ -145,15 +152,12 @@ export function compileReflexObservation( directiveProgress = 'advancing'; else if (directive.targetCell && currentDistance !== 0 && !hasForwardMove) directiveProgress = 'blocked'; - const nearbyCleaned = (history.recentCleanedCells ?? []) - .slice(-6) - .map((cell) => distance(current, h3CellSchema.parse(cell))) - .filter((value): value is number => value !== null); - const nearbyPressure = nearbyCleaned.some((value) => value <= 1) - ? ('high' as const) - : nearbyCleaned.some((value) => value <= 2) - ? ('rising' as const) - : ('low' as const); + const nearbyPressure = history.pressureEvents + ? localPressureAtCell(current, history.pressureEvents).localPressure + : localPressureFromCells( + current, + (history.recentCleanedCells ?? []).slice(-6), + ).localPressure; const territoryTrend = (history.territoryDelta ?? 0) > 0 ? ('growing' as const) diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index c028c5f..99a4c6f 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -11,10 +11,12 @@ import { } from '@hexzero/agent-runtime'; import { assignBehavior, + h3CellSchema, type CompatibleModel, type SwarmPlan, type ZeroStrategicObservation, reflexDecisionSchema, + swarmPlanSchema, singleTickResponseSchema, } from '@hexzero/shared'; import { generateDeterministicRoster } from '@hexzero/world-engine'; @@ -23,7 +25,9 @@ import { SimulationConflictError, SimulationService, SimulationTurnCancelledError, + replanThresholdForPressure, } from './simulation-service'; +import { geographicDirectionBetweenCells } from './geographic-direction'; const model = { id: 'test/zero', @@ -266,6 +270,215 @@ function setup( } describe('zero-swarm SimulationService tick', () => { + it('uses conservative and elevated-pressure worker replan thresholds', () => { + expect(0.6 >= replanThresholdForPressure('high')).toBe(true); + expect(0.6 >= replanThresholdForPressure('rising')).toBe(true); + expect(0.6 >= replanThresholdForPressure('low')).toBe(false); + expect(0.79 >= replanThresholdForPressure('low')).toBe(false); + expect(0.8 >= replanThresholdForPressure('low')).toBe(true); + }); + + it('escalates a real current-tick trail-hunter disinfection into the next Zero replan', async () => { + const workerCell = h3CellSchema.parse('892a94d2e73ffff'); + const safeMoveCell = h3CellSchema.parse('892a94d2e47ffff'); + const safeMoveDirection = geographicDirectionBetweenCells( + workerCell, + safeMoveCell, + ); + const moveToHunterDirection = geographicDirectionBetweenCells( + safeMoveCell, + workerCell, + ); + const observations: ZeroStrategicObservation[] = []; + const planner: SwarmPlanner = { + mode: 'scripted-swarm-test', + configured: true, + async plan(observation, _model, options = {}) { + observations.push(structuredClone(observation)); + const plan = { + strategySummary: 'Follow assigned positions.', + zeroActionCandidateId: observation.legalZeroActions.find( + ({ action }) => action.type === 'wait', + )!.id, + directives: observation.agents + .filter(({ agentId }) => agentId !== observation.zeroAgentId) + .map((agent) => ({ + id: `pressure-${observation.tickNumber}-${agent.agentId}`, + agentId: agent.agentId, + mission: 'hold' as const, + targetCell: agent.position, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + 5, + })), + }; + swarmPlanSchema.parse(plan); + options.beginAttempt?.('initial')?.({ + outcome: 'completed', + provider: { + provider: 'scripted-test', + model: 'test/zero', + latencyMs: 0, + }, + swarmPlan: plan, + }); + return { + plan, + metadata: { + provider: 'scripted-test', + model: 'test/zero', + latencyMs: 0, + }, + }; + }, + }; + let reflexCalls = 0; + const reflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = + reflexCalls === 0 + ? observation.candidates.find(({ description }) => + description.startsWith('Infect'), + )! + : reflexCalls === 1 + ? observation.candidates.find(({ description }) => + description.startsWith(`Move ${safeMoveDirection} `), + )! + : reflexCalls === 3 + ? observation.candidates.find(({ description }) => + description.startsWith(`Move ${moveToHunterDirection} `), + )! + : observation.candidates.find(({ description }) => + description.startsWith('Remain'), + )!; + reflexCalls += 1; + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.id, + confidence: 1, + probabilities: Object.fromEntries( + observation.candidates.map(({ id }) => [ + id, + id === choice.id ? 1 : 0, + ]), + ), + replanProbability: 0.6, + model: 'test-reflex', + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + options?.beginAttempt?.('initial')?.({ + outcome: 'completed', + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = new SimulationService({ + provider: new BrowserTestAgentProvider(), + swarmPlanner: planner, + reflexProvider: reflex, + now: () => '2026-08-13T12:00:00.000Z', + }); + simulation.setCompatibleModels([model]); + const request = simulation.getDefaultWorldSetup(); + const roster = request.roster.slice(0, 2); + simulation.applyWorldSetup({ + ...request, + cognitionMode: 'zero-swarm-v1', + roster, + patientZeroAgentId: roster[0]!.id, + spawnSeed: 'pressure-spawn-0', + objectiveVersion: 'durable-influence-v3', + capabilities: { ...request.capabilities, simulatedPlayerPressure: true }, + simulatedPlayer: { + enabled: true, + profile: 'trail-hunter-v1', + seed: 'pressure-hunter-17', + }, + 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(); + await simulation.executeNextTick(); + const firstTwo = simulation.getSnapshot().swarmTicks?.slice(0, 2) ?? []; + expect(firstTwo).toHaveLength(2); + expect( + firstTwo.every( + (tick) => + tick.workers[0]?.situation?.nearbyPressure === 'low' && + tick.workers[0]?.reflexDecision?.replanProbability === 0.6 && + tick.signals === undefined, + ), + ).toBe(true); + const third = simulation.getSnapshot().swarmTicks?.[2]; + expect(third).toBeDefined(); + expect(reflexCalls).toBe(3); + expect(third?.workers[0]?.situation?.nearbyPressure).toBe('high'); + expect(third?.workers[0]?.reflexDecision?.replanProbability).toBe(0.6); + expect(third?.signals).toEqual([ + expect.objectContaining({ + type: 'worker-replan-requested', + probability: 0.6, + }), + ]); + const zeroAtDisinfection = observations.find( + ({ tickNumber }) => tickNumber === 3, + ); + expect( + zeroAtDisinfection?.agents.find( + ({ agentId }) => agentId === roster[1]!.id, + ), + ).toMatchObject({ + localPressure: 'high', + pressureDistance: 'adjacent', + pressureDirection: geographicDirectionBetweenCells( + safeMoveCell, + workerCell, + ), + }); + await simulation.executeNextTick(); + expect(simulation.getSnapshot().swarmTicks?.[3]?.replanReasons).toContain( + 'worker-request', + ); + expect( + observations.find(({ tickNumber }) => tickNumber === 4)?.replanReasons, + ).toContain('worker-request'); + await simulation.executeNextTick(); + const afterCapture = observations.find( + ({ tickNumber }) => tickNumber === 5, + )!; + expect(afterCapture.agents.map(({ agentId }) => agentId)).toEqual([ + roster[0]!.id, + ]); + expect(afterCapture.replanReasons).toContain('roster-changed'); + expect(afterCapture.replanReasons).not.toContain('worker-request'); + expect(afterCapture.workerReplanRequests).toBeUndefined(); + expect(afterCapture.recentCaptures).toEqual([ + expect.objectContaining({ capturedAgentId: roster[1]!.id }), + ]); + }); + it('commits a player-only terminal tick when trail hunter captures Patient Zero', async () => { const planner = new InspectingPlanner(); const simulation = setup( @@ -387,6 +600,9 @@ describe('zero-swarm SimulationService tick', () => { expect(planner.observations[0]?.recentPlayerPressure).toEqual( expect.arrayContaining([expect.stringContaining(roster[0]!.id)]), ); + expect(planner.observations[0]?.recentCaptures).toEqual([ + expect.objectContaining({ capturedAgentId: roster[0]!.id }), + ]); expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(1); }); @@ -560,6 +776,7 @@ describe('zero-swarm SimulationService tick', () => { action?.type === 'infect' && actionResult?.accepted === true, ), ).toBe(true); + expect(tick?.signals).toBeUndefined(); expect( simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, ).toBe(1); @@ -924,6 +1141,7 @@ describe('zero-swarm SimulationService tick', () => { source === 'deterministic-fallback' && action?.type === 'wait', ), ).toBe(true); + expect(simulation.getSnapshot().swarmTicks?.[0]?.signals).toBeUndefined(); expect( simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, ).toBe(8); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index ac73b8b..07fa33d 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -113,6 +113,11 @@ import { ExperimentMetricAccumulator, } from './experiment-export'; import { geographicDirectionBetweenCells } from './geographic-direction'; +import { + boundedPressureEvents, + boundedRecentCaptures, + localPressureAtCell, +} from './swarm-pressure'; import { ObservationHistory } from './observation-history'; import { AttemptAccounting } from './attempt-accounting'; import { @@ -143,6 +148,16 @@ const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; const MAX_TURN_HISTORY = 120; const MAX_WORLD_EVENT_HISTORY = 120; const DEFAULT_EXPERIMENT_RETENTION = 5_000; +export const LOW_PRESSURE_REPLAN_THRESHOLD = 0.8; +export const ELEVATED_PRESSURE_REPLAN_THRESHOLD = 0.5; + +export function replanThresholdForPressure( + pressure: 'low' | 'rising' | 'high', +): number { + return pressure === 'low' + ? LOW_PRESSURE_REPLAN_THRESHOLD + : ELEVATED_PRESSURE_REPLAN_THRESHOLD; +} function chooseDeterministicWorkerAction( compiled: CompiledReflexObservation, @@ -1602,6 +1617,12 @@ export class SimulationService { { createEventId: this.#createEventId, now: () => virtualTime }, ); const candidate = playerAdvance.state; + // Include public effects of this advance before workers make their choices. + // This array is derived only; player events remain committed once below. + const pressureEvents = boundedPressureEvents( + this.#simulatedPlayerEvents, + playerAdvance.events, + ); const agents = [...candidate.agents.values()]; const zero = zeroAgentId ? candidate.agents.get(zeroAgentId) : undefined; if (!agents.length || !zero) { @@ -1775,17 +1796,7 @@ export class SimulationService { this.#activeAgentId = worker.id; const history = { previousCell: this.#lastSwarmPositions.get(worker.id), - recentCleanedCells: this.#simulatedPlayerEvents - .filter( - ( - event, - ): event is Extract< - SimulatedPlayerEvent, - { type: 'hex-disinfected' } - > => event.type === 'hex-disinfected', - ) - .slice(-6) - .map(({ cell }) => cell), + pressureEvents, captureAlerts: captureAlertsFrom(playerAdvance.events), territoryDelta: this.#lastSwarmTerritoryDeltas.get(worker.id) ?? 0, recentActionOutcome: this.#swarmTicks @@ -1856,7 +1867,9 @@ export class SimulationService { const signals = workers.flatMap((worker) => { const selection = selected.get(worker.id)!; const probability = selection.decision?.replanProbability; - return probability !== undefined && probability >= 0.8 + const pressure = selection.observation.currentSituation.nearbyPressure; + const threshold = replanThresholdForPressure(pressure); + return probability !== undefined && probability >= threshold ? [ { type: 'worker-replan-requested' as const, @@ -2777,7 +2790,16 @@ export class SimulationService { : 'Wait on the current cell.', }), ); - const workerReplanRequests = this.#swarmWorkerReplanRequests(); + const workerReplanRequests = this.#swarmWorkerReplanRequests(state); + const pressureEvents = boundedPressureEvents( + this.#simulatedPlayerEvents, + playerEvents, + ); + const recentCaptures = boundedRecentCaptures( + this.#simulatedPlayerEvents, + playerEvents, + tickNumber, + ); return { zeroAgentId, tickNumber, @@ -2789,6 +2811,10 @@ export class SimulationService { hex.state === 'infected' ? hex.controllerAgentId : null, })), agents: [...state.agents.values()].map((agent) => { + const localThreat = localPressureAtCell( + agent.currentCell, + pressureEvents, + ); const priorWorker = this.#swarmTicks .at(-1) ?.workers.find(({ agentId }) => agentId === agent.id); @@ -2822,18 +2848,22 @@ export class SimulationService { position: agent.currentCell, controlledCellCount: counts.get(agent.id) ?? 0, territoryDelta: this.#lastSwarmTerritoryDeltas.get(agent.id) ?? 0, + localPressure: localThreat.localPressure, + pressureDirection: localThreat.pressureDirection, + pressureDistance: localThreat.pressureDistance, ...(agent.id === zeroAgentId ? {} : { workerStatus, directive }), }; }), recentPlayerPressure: playerEvents.map((event) => event.type === 'hex-disinfected' - ? 'A nearby infected cell was cleaned this tick.' + ? 'An infected cell was cleaned this tick.' : event.type === 'simulated-player-clean-blocked' ? 'Cleaning pressure was blocked by an occupied infected cell.' : 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.', ), + ...(recentCaptures.length ? { recentCaptures } : {}), ...(replanReasons.length ? { replanReasons: [...replanReasons] } : {}), ...(completedDirectives.length ? { completedDirectives: [...completedDirectives] } @@ -2904,7 +2934,7 @@ export class SimulationService { ) ) reasons.push('directive-expired'); - if (this.#swarmWorkerReplanRequests().length) + if (this.#swarmWorkerReplanRequests(state).length) reasons.push('worker-request'); const recent = this.#swarmTicks.slice(-2); if ( @@ -2955,18 +2985,18 @@ export class SimulationService { return reasons; } - #swarmWorkerReplanRequests(): Array<{ + #swarmWorkerReplanRequests(state: WorldState = this.#state): Array<{ agentId: AgentId; directiveId: string; probability: number; }> { - return (this.#swarmTicks.at(-1)?.signals ?? []).map( - ({ agentId, directiveId, probability }) => ({ + return (this.#swarmTicks.at(-1)?.signals ?? []) + .filter(({ agentId }) => state.agents.has(agentId)) + .map(({ agentId, directiveId, probability }) => ({ agentId, directiveId, probability, - }), - ); + })); } #reusedSwarmPlan(state: WorldState, zeroAgentId: AgentId): SwarmPlan { diff --git a/apps/game-api/src/swarm-pressure.test.ts b/apps/game-api/src/swarm-pressure.test.ts new file mode 100644 index 0000000..f01f108 --- /dev/null +++ b/apps/game-api/src/swarm-pressure.test.ts @@ -0,0 +1,73 @@ +import { gridDisk } from 'h3-js'; +import { describe, expect, it } from 'vitest'; +import { + simulatedPlayerEventSchema, + type SimulatedPlayerEvent, + type H3Cell, +} from '@hexzero/shared'; +import { createDevelopmentWorld, toWorldState } from '@hexzero/world-engine'; +import { boundedPressureEvents, localPressureAtCell } from './swarm-pressure'; + +const timestamp = '2026-08-13T12:00:00.000Z'; + +function disinfection(cell: H3Cell, id: string, tick = 1) { + return simulatedPlayerEventSchema.parse({ + id, + occurredAt: timestamp, + profile: 'trail-hunter-v1', + originatingTick: tick, + type: 'hex-disinfected', + cell, + previousControllerAgentId: null, + }) as Extract; +} + +describe('swarm pressure', () => { + const state = toWorldState(createDevelopmentWorld()); + const cell = [...state.agents.values()][1]!.currentCell; + const ring = gridDisk(cell, 3) as H3Cell[]; + const adjacent = ring.find((candidate) => candidate !== cell)!; + const nearby = ring.find( + (candidate) => !gridDisk(cell, 1).includes(candidate), + )!; + const distant = ring.find( + (candidate) => !gridDisk(cell, 2).includes(candidate), + )!; + + it('classifies one-cell, two-cell, and distant public disinfections', () => { + expect( + localPressureAtCell(cell, [ + disinfection(adjacent, '00000000-0000-4000-8000-000000000001'), + ]).localPressure, + ).toBe('high'); + expect( + localPressureAtCell(cell, [ + disinfection(nearby, '00000000-0000-4000-8000-000000000002'), + ]).localPressure, + ).toBe('rising'); + expect( + localPressureAtCell(cell, [ + disinfection(distant, '00000000-0000-4000-8000-000000000003'), + ]), + ).toEqual({ + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }); + }); + + it('bounds retained plus current public events without duplication', () => { + const events = Array.from({ length: 7 }, (_, index) => + disinfection( + index % 2 === 0 ? adjacent : nearby, + `00000000-0000-4000-8000-0000000000${10 + index}`, + index + 1, + ), + ); + const combined = boundedPressureEvents(events, [events[1]!, ...events]); + expect(combined.map(({ id }) => id)).toEqual( + events.slice(-6).map(({ id }) => id), + ); + expect(combined).toHaveLength(6); + }); +}); diff --git a/apps/game-api/src/swarm-pressure.ts b/apps/game-api/src/swarm-pressure.ts new file mode 100644 index 0000000..85e66ef --- /dev/null +++ b/apps/game-api/src/swarm-pressure.ts @@ -0,0 +1,145 @@ +import { gridDistance } from 'h3-js'; +import type { + CaptureAlert, + H3Cell, + SimulatedPlayerEvent, +} from '@hexzero/shared'; +import { + geographicDirectionBetweenCells, + type GeographicDirection, +} from './geographic-direction'; + +export const MAX_SWARM_PRESSURE_EVENTS = 6; +export const RECENT_CAPTURE_WINDOW_TICKS = 6; + +type DisinfectionEvent = Extract< + SimulatedPlayerEvent, + { type: 'hex-disinfected' } +>; + +export interface LocalPressureContext { + localPressure: 'low' | 'rising' | 'high'; + pressureDirection: GeographicDirection | null; + pressureDistance: 'same-cell' | 'adjacent' | 'nearby' | null; +} + +/** + * Combines retained and current public disinfection effects for cognition. + * Event IDs make this safe when an already-retained event is supplied again. + */ +export function boundedPressureEvents( + retained: readonly SimulatedPlayerEvent[], + current: readonly SimulatedPlayerEvent[], +): DisinfectionEvent[] { + const seen = new Set(); + return [...retained, ...current] + .filter( + (event): event is DisinfectionEvent => event.type === 'hex-disinfected', + ) + .filter((event) => { + if (seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) + .slice(-MAX_SWARM_PRESSURE_EVENTS); +} + +/** Bounded public capture effects for the strategic planner. */ +export function boundedRecentCaptures( + retained: readonly SimulatedPlayerEvent[], + current: readonly SimulatedPlayerEvent[], + currentTick: number, +): CaptureAlert[] { + const seen = new Set(); + return [...retained, ...current] + .filter( + ( + event, + ): event is Extract< + SimulatedPlayerEvent, + { type: 'simulated-player-agent-captured' } + > => event.type === 'simulated-player-agent-captured', + ) + .filter((event) => { + if (seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) + .filter( + (event) => + event.originatingTick >= + currentTick - RECENT_CAPTURE_WINDOW_TICKS + 1 && + event.originatingTick <= currentTick, + ) + .slice(-4) + .map(({ capturedAgentId, cell, originatingTick, abandonedCellCount }) => ({ + capturedAgentId, + cell, + originatingTick, + abandonedCellCount, + })); +} + +/** Derives bounded local threat facts from public world effects and H3 geometry. */ +export function localPressureAtCell( + cell: H3Cell, + events: readonly DisinfectionEvent[], +): LocalPressureContext { + return localPressureFromCells( + cell, + events.map(({ cell: eventCell }) => eventCell), + ); +} + +/** Shared geometry semantics for both event and compatibility cell histories. */ +export function localPressureFromCells( + cell: H3Cell, + pressureCells: readonly H3Cell[], +): LocalPressureContext { + const nearby = pressureCells + .map((pressureCell, index) => { + try { + return { + pressureCell, + distance: gridDistance(cell, pressureCell), + index, + }; + } catch { + return null; + } + }) + .filter( + ( + value, + ): value is { + pressureCell: H3Cell; + distance: number; + index: number; + } => value !== null && value.distance <= 2, + ) + .sort( + (left, right) => + left.distance - right.distance || right.index - left.index, + ); + const nearest = nearby[0]; + if (!nearest) + return { + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }; + const pressureDistance = + nearest.distance === 0 + ? ('same-cell' as const) + : nearest.distance === 1 + ? ('adjacent' as const) + : ('nearby' as const); + return { + localPressure: nearest.distance <= 1 ? 'high' : 'rising', + pressureDirection: + nearest.distance === 0 + ? null + : geographicDirectionBetweenCells(cell, nearest.pressureCell), + pressureDistance, + }; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7fa5fc9..510a7be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,6 +18,15 @@ The worker observation includes bounded, current capture alerts. The TypeSafe request projects them as structured capture pressure without cell IDs or tick numbers. The unused prose `relevantRecentFacts` field is removed; current situation and legal candidate descriptions supply the relevant local facts. +For zero-swarm ticks, public disinfection events from the current simulated-player +advance join retained events before either Zero or workers observe pressure; +they enter committed history only after the tick succeeds. One shared geometry +rule classifies a disinfection at most one cell away as high pressure, two cells +away as rising pressure, and otherwise low pressure. Zero receives that local +classification and bounded direction/distance categories for each surviving +agent, plus recent observable captures. A real Jev replan probability triggers +a worker request at 0.50 under rising or high pressure and at 0.80 under low +pressure; deterministic fallbacks cannot emit that signal. Directive progress compares each worker's current cell with its position before the prior tick's physical action. Reaching the directive target has its own `at-target` observation status. Zero's worker status uses the same position diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 4ba5d16..dcf8fb6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -53,6 +53,10 @@ request as the action choice. Code applies the replan threshold and stores a structured signal; it grants no action or mutation authority. Reused directives are checked against expiry, and Zero's physical action comes from current legal engine affordances rather than an earlier plan's candidate ID. +Zero's local threat fields come from public disinfection effects and H3 geometry; +bounded capture context comes from public capture events. Neither projection +reads the hunter's selected target, planned route, or hidden state. Current +events are projected before worker decisions without being committed early. In `zero-swarm-v1`, a separate OpenRouter planner receives bounded strategic facts, an engine-generated target allowlist, and opaque legal Zero action IDs. diff --git a/docs/TESTING.md b/docs/TESTING.md index 4f017f5..81192b2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -41,6 +41,9 @@ fallbacks for immediate diagnosis of apparently stationary ticks. Focused offline progress tests cover a worker advancing after a move, reaching a directive target, waiting at that target, and the corresponding status seen by Zero on the next planning tick. +Offline swarm pressure tests cover current-tick disinfection proximity, bounded +event history, capture context, pressure-aware replan thresholds, and the +following tick's worker-request reason. No provider network calls are made. Directive-lifecycle tests also cover expand completion only after worker control, relocate and reinforce completion on arrival, with reinforce target validity checked when issued; evade completion on arrival or diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index 088d8da..d4e99dd 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -23,12 +23,18 @@ const observation = zeroStrategicObservationSchema.parse({ position: cell, controlledCellCount: 1, territoryDelta: 0, + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, }, { agentId: worker, position: cell, controlledCellCount: 0, territoryDelta: 0, + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, }, ], recentPlayerPressure: [], @@ -146,6 +152,66 @@ describe('swarm planners', () => { }); }); + it('gives Zero bounded, event-derived worker threat and capture context', async () => { + const threatenedObservation = zeroStrategicObservationSchema.parse({ + ...observation, + agents: observation.agents.map((agent) => + agent.agentId === worker + ? { + ...agent, + localPressure: 'high', + pressureDirection: 'NE', + pressureDistance: 'adjacent', + } + : agent, + ), + recentCaptures: [ + { + capturedAgentId: worker, + cell, + originatingTick: 1, + abandonedCellCount: 3, + }, + ], + }); + const fetchImplementation = vi + .fn() + .mockResolvedValue(plannerResponse(compactPlan)); + await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation, + }).plan(threatenedObservation, 'test-model'); + const body = JSON.parse( + String(fetchImplementation.mock.calls[0]?.[1]?.body), + ) as { messages: Array<{ content: string }> }; + expect(body.messages[0]?.content).toContain( + 'permanently captured and removed', + ); + expect(body.messages[0]?.content).toContain( + 'Hold under high pressure only as an intentional defensive or sacrifice choice', + ); + expect(JSON.parse(body.messages[1]!.content)).toMatchObject({ + workers: [ + { + workerId: 'worker_0', + localPressure: 'high', + pressureDirection: 'NE', + pressureDistance: 'adjacent', + }, + ], + recentCaptures: [ + { + capturedAgentId: worker, + cell, + originatingTick: 1, + abandonedCellCount: 3, + }, + ], + }); + expect(body.messages[1]?.content).not.toContain('targetingState'); + expect(body.messages[1]?.content).not.toContain('simulatedPlayerPosition'); + }); + it('rejects unknown compact choices with a safe specific reason', async () => { await expect( new OpenRouterSwarmPlanner({ diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index 955a4b0..001e971 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -429,6 +429,9 @@ function buildSwarmPlannerRequest( position: agent.position, controlledCellCount: agent.controlledCellCount, territoryDelta: agent.territoryDelta, + localPressure: agent.localPressure, + pressureDirection: agent.pressureDirection, + pressureDistance: agent.pressureDistance, workerStatus: agent.workerStatus ?? 'unknown', directiveComplete: agent.directive !== null && @@ -477,7 +480,7 @@ function buildSwarmPlannerRequest( { role: 'system', content: - "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, and workerReplanRequests when present. Non-hold missions need a target. Expand targets must be open cells. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", + "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, workerReplanRequests, worker localPressure, spatial pressure categories, and recentCaptures when present. Under trail-hunter pressure, a worker caught by the simulated player is permanently captured and removed, and its controlled territory becomes abandoned. Treat sustained high local pressure as an existential threat. Hold under high pressure only as an intentional defensive or sacrifice choice. Non-hold missions need a target. Expand targets must be open cells. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", }, { role: 'user', @@ -492,6 +495,7 @@ function buildSwarmPlannerRequest( }, workers, recentPlayerPressure: observation.recentPlayerPressure, + recentCaptures: observation.recentCaptures ?? [], replanReasons: observation.replanReasons ?? [], workerReplanRequests: ( observation.workerReplanRequests ?? [] diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 49d70ec..5adef48 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -60,6 +60,7 @@ import { createMemoryId, archiveExperimentExportResponseSchema, providerAttemptRecordSchema, + zeroStrategicObservationSchema, } from '.'; const agentId = '128f3f38-6b7d-4db7-9e95-751b4ce2681e'; @@ -1194,6 +1195,69 @@ describe('agent observation and decision schemas', () => { }); }); +describe('Zero strategic observation schema', () => { + const strategicObservation = { + zeroAgentId: agentId, + tickNumber: 2, + virtualTime: '2026-08-13T12:00:00.000Z', + cells: [{ cell, state: 'infected' as const, controllerAgentId: agentId }], + agents: [ + { + agentId, + position: cell, + controlledCellCount: 1, + territoryDelta: 0, + localPressure: 'low' as const, + pressureDirection: null, + pressureDistance: null, + }, + { + agentId: scoreboard[1]!.agentId, + position: cell, + controlledCellCount: 0, + territoryDelta: 0, + localPressure: 'rising' as const, + pressureDirection: 'NE' as const, + pressureDistance: 'adjacent' as const, + }, + ], + recentPlayerPressure: [], + legalZeroActions: [ + { + id: 'zero_action_0', + action: { type: 'wait' as const }, + description: 'Wait on the current cell.', + }, + ], + strategicTargetCells: [cell], + }; + + it('requires bounded semantic worker threat fields and caps recent captures', () => { + expect( + zeroStrategicObservationSchema.safeParse(strategicObservation).success, + ).toBe(true); + expect( + zeroStrategicObservationSchema.safeParse({ + ...strategicObservation, + agents: strategicObservation.agents.map((agent, index) => + index === 1 ? { ...agent, localPressure: undefined } : agent, + ), + }).success, + ).toBe(false); + expect( + zeroStrategicObservationSchema.safeParse({ + ...strategicObservation, + recentCaptures: Array.from({ length: 5 }, () => ({ + capturedAgentId: scoreboard[1]!.agentId, + cell, + originatingTick: 2, + abandonedCellCount: 1, + })), + }).success, + ).toBe(false); + }); +}); + describe('reasoning profiles', () => { const model: CompatibleModel = { id: 'example/reasoning-model', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0c5e2a9..6c11de9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -681,6 +681,30 @@ export type CompletedSwarmDirective = z.infer< typeof completedSwarmDirectiveSchema >; +export const localPressureSchema = z.enum(['low', 'rising', 'high']); +export type LocalPressure = z.infer; + +/** + * Bounded, event-derived spatial context for Agent Zero. These categories are + * deliberately coarser than H3 cells or simulated-player state. + */ +export const pressureDirectionSchema = z.enum([ + 'N', + 'NE', + 'SE', + 'S', + 'SW', + 'NW', +]); +export type PressureDirection = z.infer; + +export const pressureDistanceSchema = z.enum([ + 'same-cell', + 'adjacent', + 'nearby', +]); +export type PressureDistance = z.infer; + export const swarmSignalSchema = z .object({ type: z.literal('worker-replan-requested'), @@ -719,6 +743,9 @@ export const zeroStrategicObservationSchema = z position: h3CellSchema, controlledCellCount: z.number().int().nonnegative(), territoryDelta: z.number().int(), + localPressure: localPressureSchema, + pressureDirection: pressureDirectionSchema.nullable(), + pressureDistance: pressureDistanceSchema.nullable(), workerStatus: z .enum(['advancing', 'at-target', 'stalled', 'blocked', 'unknown']) .optional(), @@ -729,6 +756,7 @@ export const zeroStrategicObservationSchema = z .min(1) .max(WORLD_SCENARIO_LIMITS.maximumAgents), recentPlayerPressure: z.array(z.string().trim().min(1).max(180)).max(12), + recentCaptures: z.array(captureAlertSchema).max(4).optional(), replanReasons: z.array(swarmReplanReasonSchema).max(10).optional(), completedDirectives: z .array(completedSwarmDirectiveSchema) From 3e292a1788915004c1e0e746394d320bab1cffd4 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 22:59:15 -0400 Subject: [PATCH 2/2] fix(swarm): expire stale disinfection pressure --- apps/game-api/src/simulation-service.ts | 2 + apps/game-api/src/swarm-pressure.test.ts | 54 ++++++++++++++++++++++-- apps/game-api/src/swarm-pressure.ts | 8 ++++ docs/ARCHITECTURE.md | 3 +- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 07fa33d..a0ee331 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1622,6 +1622,7 @@ export class SimulationService { const pressureEvents = boundedPressureEvents( this.#simulatedPlayerEvents, playerAdvance.events, + tickNumber, ); const agents = [...candidate.agents.values()]; const zero = zeroAgentId ? candidate.agents.get(zeroAgentId) : undefined; @@ -2794,6 +2795,7 @@ export class SimulationService { const pressureEvents = boundedPressureEvents( this.#simulatedPlayerEvents, playerEvents, + tickNumber, ); const recentCaptures = boundedRecentCaptures( this.#simulatedPlayerEvents, diff --git a/apps/game-api/src/swarm-pressure.test.ts b/apps/game-api/src/swarm-pressure.test.ts index f01f108..3d7e442 100644 --- a/apps/game-api/src/swarm-pressure.test.ts +++ b/apps/game-api/src/swarm-pressure.test.ts @@ -56,15 +56,63 @@ describe('swarm pressure', () => { }); }); - it('bounds retained plus current public events without duplication', () => { + it('retains current and prior five ticks of public disinfections', () => { + const currentTick = 10; + const combined = boundedPressureEvents( + [ + disinfection(adjacent, '00000000-0000-4000-8000-000000000010', 5), + disinfection(adjacent, '00000000-0000-4000-8000-000000000011', 4), + ], + [ + disinfection(nearby, '00000000-0000-4000-8000-000000000012', 10), + disinfection(nearby, '00000000-0000-4000-8000-000000000013', 11), + ], + currentTick, + ); + + expect(combined.map(({ id }) => id)).toEqual([ + '00000000-0000-4000-8000-000000000010', + '00000000-0000-4000-8000-000000000012', + ]); + expect(localPressureAtCell(cell, combined).localPressure).toBe('high'); + expect( + localPressureAtCell( + cell, + boundedPressureEvents( + [], + [disinfection(nearby, '00000000-0000-4000-8000-000000000012', 10)], + currentTick, + ), + ).localPressure, + ).toBe('rising'); + }); + + it('expires old high and rising disinfections before deriving local pressure', () => { + const combined = boundedPressureEvents( + [ + disinfection(adjacent, '00000000-0000-4000-8000-000000000014', 4), + disinfection(nearby, '00000000-0000-4000-8000-000000000015', 3), + ], + [], + 10, + ); + + expect(localPressureAtCell(cell, combined)).toEqual({ + localPressure: 'low', + pressureDirection: null, + pressureDistance: null, + }); + }); + + it('deduplicates then caps retained plus current in-window events', () => { const events = Array.from({ length: 7 }, (_, index) => disinfection( index % 2 === 0 ? adjacent : nearby, `00000000-0000-4000-8000-0000000000${10 + index}`, - index + 1, + 10, ), ); - const combined = boundedPressureEvents(events, [events[1]!, ...events]); + const combined = boundedPressureEvents(events, [events[1]!, ...events], 10); expect(combined.map(({ id }) => id)).toEqual( events.slice(-6).map(({ id }) => id), ); diff --git a/apps/game-api/src/swarm-pressure.ts b/apps/game-api/src/swarm-pressure.ts index 85e66ef..b3828a7 100644 --- a/apps/game-api/src/swarm-pressure.ts +++ b/apps/game-api/src/swarm-pressure.ts @@ -10,6 +10,7 @@ import { } from './geographic-direction'; export const MAX_SWARM_PRESSURE_EVENTS = 6; +export const SWARM_PRESSURE_WINDOW_TICKS = 6; export const RECENT_CAPTURE_WINDOW_TICKS = 6; type DisinfectionEvent = Extract< @@ -30,12 +31,19 @@ export interface LocalPressureContext { export function boundedPressureEvents( retained: readonly SimulatedPlayerEvent[], current: readonly SimulatedPlayerEvent[], + currentTick: number, ): DisinfectionEvent[] { const seen = new Set(); return [...retained, ...current] .filter( (event): event is DisinfectionEvent => event.type === 'hex-disinfected', ) + .filter( + (event) => + event.originatingTick >= + currentTick - SWARM_PRESSURE_WINDOW_TICKS + 1 && + event.originatingTick <= currentTick, + ) .filter((event) => { if (seen.has(event.id)) return false; seen.add(event.id); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 510a7be..aa619ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,8 @@ they enter committed history only after the tick succeeds. One shared geometry rule classifies a disinfection at most one cell away as high pressure, two cells away as rising pressure, and otherwise low pressure. Zero receives that local classification and bounded direction/distance categories for each surviving -agent, plus recent observable captures. A real Jev replan probability triggers +agent, plus recent observable captures. Disinfection pressure uses the current +and prior five ticks, capped at six events. A real Jev replan probability triggers a worker request at 0.50 under rising or high pressure and at 0.80 under low pressure; deterministic fallbacks cannot emit that signal. Directive progress compares each worker's current cell with its position before