diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index 2605c94..d3cac68 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -1357,6 +1357,17 @@ function exportTurn( ...observation.playerPressure, recentThreats: [], }; + const globalThreatFeed = + observation.patientZeroGlobalView?.playerThreatFeed; + if (custom && !custom.recentControlChanges && globalThreatFeed) + observation.patientZeroGlobalView = { + ...observation.patientZeroGlobalView!, + playerThreatFeed: { + events: [], + totalEventCount: globalThreatFeed.totalEventCount, + truncated: globalThreatFeed.totalEventCount > 0, + }, + }; base.observation = observation; } if ( diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index ae7583b..003157b 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -41,6 +41,7 @@ import { SimulationTurnCancelledError, SimulationValidationError, selectDiplomacyBlockerExamples, + selectMostRecentPatientZeroThreats, applyGoalRevision, applyMemoryOperation, } from './simulation-service'; @@ -98,6 +99,21 @@ function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') { } describe('SimulationService', () => { + it('retains the most recent Patient Zero threats in chronological order', () => { + const threats = Array.from({ length: 130 }, (_, index) => ({ + eventId: `30000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + occurredAt: new Date( + new Date('2026-08-23T12:00:00.000Z').getTime() + index, + ).toISOString(), + ordinal: index, + })).reverse(); + const selected = selectMostRecentPatientZeroThreats(threats); + expect(selected).toHaveLength(128); + expect(selected.map(({ ordinal }) => ordinal)).toEqual( + Array.from({ length: 128 }, (_, index) => index + 2), + ); + }); + it('commits cleaner pressure before frozen observations without exposing live GPS', async () => { const seen: AgentObservation[] = []; const simulation = service({ @@ -162,6 +178,61 @@ describe('SimulationService', () => { expect(pressured.length).toBeGreaterThan(0); expect(pressured[0]!.playerPressure).not.toHaveProperty('currentCell'); expect(snapshot.world.simulatedPlayer?.currentCell).toBeTruthy(); + const patientZeroObservations = seen.filter( + ({ patientZero }) => patientZero.isPatientZero, + ); + const ordinaryObservations = seen.filter( + ({ patientZero }) => !patientZero.isPatientZero, + ); + expect( + ordinaryObservations.every( + ({ patientZeroGlobalView }) => patientZeroGlobalView === null, + ), + ).toBe(true); + expect( + patientZeroObservations.every( + ({ patientZeroGlobalView }) => + patientZeroGlobalView?.playerThreatFeed !== null, + ), + ).toBe(true); + const intervalEventKeys = patientZeroObservations.map( + ({ patientZeroGlobalView }) => + patientZeroGlobalView!.playerThreatFeed!.events.map( + ({ eventId, kind, cell, occurredAt }) => + `${eventId}:${kind}:${cell}:${occurredAt}`, + ), + ); + expect(intervalEventKeys.flat().length).toBeGreaterThan(0); + expect(new Set(intervalEventKeys.flat()).size).toBe( + intervalEventKeys.flat().length, + ); + expect( + patientZeroObservations.flatMap( + ({ patientZeroGlobalView }) => + patientZeroGlobalView!.playerThreatFeed!.events, + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'occupied-clean-blocked', + blockingAgentId: expect.any(String), + blockingAgentName: expect.any(String), + }), + expect.objectContaining({ + kind: 'territory-disinfected', + affectedAgentId: expect.any(String), + affectedAgentName: expect.any(String), + }), + ]), + ); + expect( + JSON.stringify( + patientZeroObservations.map( + ({ patientZeroGlobalView }) => + patientZeroGlobalView?.playerThreatFeed, + ), + ), + ).not.toMatch(/fromCell|toCell|currentCell|route|target|profile|playerId/i); const redacted = simulation.generateExperimentExport({ ...exportRequest('custom'), custom: { @@ -190,6 +261,20 @@ describe('SimulationService', () => { ), ).toBe(true); expect(redacted).not.toHaveProperty('simulatedPlayerMetrics'); + const redactedGlobalFeeds = redacted.turns + .map( + ({ observation }) => + observation?.patientZeroGlobalView?.playerThreatFeed, + ) + .filter((feed) => feed !== null && feed !== undefined); + expect(redactedGlobalFeeds.length).toBeGreaterThan(0); + expect( + redactedGlobalFeeds.every( + (feed) => + feed.events.length === 0 && + feed.truncated === feed.totalEventCount > 0, + ), + ).toBe(true); }); it('keeps compact memory canonical and rejects full or missing operations independently', () => { @@ -1318,6 +1403,9 @@ describe('SimulationService', () => { const directive = await simulation.executeNextTurn(); const reply = await simulation.executeNextTurn(); expect(directive.observation.patientZeroGlobalView?.agents).toHaveLength(8); + expect( + directive.observation.patientZeroGlobalView?.playerThreatFeed, + ).toBeNull(); const diplomacySummary = directive.observation.patientZeroGlobalView?.diplomacySummary; expect(diplomacySummary).toMatchObject({ diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 01d6f80..36a8390 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -34,6 +34,7 @@ import { OPENROUTER_429_FALLBACK_BACKOFF_MS, WORLD_SCENARIO_LIMITS, PATIENT_ZERO_DIPLOMACY_SUMMARY_LIMITS, + PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT, MEMORY_ENTRY_LIMIT, personalitySchema, simulationSnapshotSchema, @@ -105,6 +106,18 @@ const MAX_TURN_HISTORY = 120; const MAX_WORLD_EVENT_HISTORY = 120; const DEFAULT_EXPERIMENT_RETENTION = 5_000; +export function selectMostRecentPatientZeroThreats< + T extends { eventId: string; occurredAt: string }, +>(events: readonly T[]): T[] { + return [...events] + .sort( + (left, right) => + left.occurredAt.localeCompare(right.occurredAt) || + left.eventId.localeCompare(right.eventId), + ) + .slice(-PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT); +} + interface PendingFailedTurn { turnNumber: number; agentId: AgentId; @@ -2090,6 +2103,59 @@ export class SimulationService { affectedOwnTerritory, })) : []; + const patientZeroPlayerThreats = this.#scenario.capabilities + .simulatedPlayerPressure + ? this.#state.events + .filter( + ( + event, + ): event is Extract< + WorldEvent, + { type: 'hex-disinfected' | 'simulated-player-clean-blocked' } + > => + (event.type === 'hex-disinfected' || + event.type === 'simulated-player-clean-blocked') && + event.originatingTick === this.#completedTickCount + 1, + ) + .toSorted( + (left, right) => + left.occurredAt.localeCompare(right.occurredAt) || + left.id.localeCompare(right.id), + ) + .map((event) => { + const referencedAgentId = + event.type === 'hex-disinfected' + ? event.previousControllerAgentId + : event.blockingAgentId; + const referencedAgent = this.#state.agents.get(referencedAgentId); + if (!referencedAgent) + throw new Error( + 'A simulated-player threat references an unknown agent.', + ); + const alliance = getAgentAlliance(this.#state, referencedAgentId); + 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, + } + : { + 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, + }; + }) + : []; return agentObservationSchema.parse({ agentId: agent.id, agentName: agent.name, @@ -2220,6 +2286,18 @@ export class SimulationService { event.type === 'hex-captured', ) .slice(-RECENT_CONTROL_CHANGE_LIMIT), + playerThreatFeed: this.#scenario.capabilities + .simulatedPlayerPressure + ? { + events: selectMostRecentPatientZeroThreats( + patientZeroPlayerThreats, + ), + totalEventCount: patientZeroPlayerThreats.length, + truncated: + patientZeroPlayerThreats.length > + PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT, + } + : null, } : null, territoryScoreboard: this.#territoryScoreboard(), diff --git a/apps/world-lab/src/components/behavior-trace.test.ts b/apps/world-lab/src/components/behavior-trace.test.ts index 7f3896e..78ddb8b 100644 --- a/apps/world-lab/src/components/behavior-trace.test.ts +++ b/apps/world-lab/src/components/behavior-trace.test.ts @@ -20,6 +20,7 @@ function acceptedTurn( inboundMessage?: boolean; territoryChange?: boolean; continuity?: boolean; + playerThreats?: boolean; } = {}, ): AgentTurnRecord { const move = options.move ?? false; @@ -115,6 +116,73 @@ function acceptedTurn( }, ] : [], + ...(options.playerThreats + ? { + patientZero: { + agentId, + agentName: 'Ember', + isPatientZero: true, + directRangeBypass: true, + }, + patientZeroGlobalView: { + agents: [], + individualTerritory: [ + { + agentId, + name: 'Ember', + color: '#d55e00', + allianceId: null, + effectiveColor: '#d55e00', + controlledCellCount: 0, + }, + ], + allianceTerritory: [], + alliances: [], + activeAllianceProposals: [], + recentStrategicEvents: [], + recentTerritoryChanges: [], + playerThreatFeed: { + events: [ + { + eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'territory-disinfected', + cell: adjacentCell, + occurredAt, + affectedAgentId: agentId, + affectedAgentName: 'Ember', + affectedAllianceId: null, + affectedAllianceColor: null, + }, + { + eventId: 'a7aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'occupied-clean-blocked', + cell: currentCell, + occurredAt, + blockingAgentId: otherAgentId, + blockingAgentName: 'Rook', + blockingAllianceId: null, + blockingAllianceColor: null, + }, + ], + totalEventCount: 3, + truncated: true, + }, + }, + playerPressure: { + enabled: true, + recentThreats: [ + { + eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'territory-disinfected', + cell: adjacentCell, + occurredAt, + distanceCells: 0, + affectedOwnTerritory: true, + }, + ], + }, + } + : {}), }, outcome: 'accepted', worldAction: move @@ -243,4 +311,43 @@ describe('deriveBehaviorTrace', () => { ], }); }); + + it('shows local and Patient Zero cleaner evidence without duplicating one event', () => { + const trace = deriveBehaviorTrace( + [acceptedTurn(1), acceptedTurn(2, { playerThreats: true })], + agentId, + ); + const evidence = trace[0]!.evidence; + expect(evidence.slice(0, 2)).toEqual([ + expect.objectContaining({ + kind: 'patient-zero-threat', + label: 'Patient Zero global cleaner feed: 2/3 displayed · truncated', + }), + expect.objectContaining({ + kind: 'player-threat', + label: expect.stringContaining('own territory disinfected'), + }), + ]); + expect(evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'player-threat', + label: expect.stringContaining('own territory disinfected'), + cell: adjacentCell, + }), + expect.objectContaining({ + kind: 'patient-zero-threat', + label: 'Patient Zero global cleaner feed: 2/3 displayed · truncated', + }), + expect.objectContaining({ + kind: 'patient-zero-threat', + label: expect.stringContaining('Rook blocked a clean'), + cell: currentCell, + }), + ]), + ); + expect( + evidence.filter(({ label }) => label.includes('Ember lost')), + ).toHaveLength(0); + }); }); diff --git a/apps/world-lab/src/components/behavior-trace.ts b/apps/world-lab/src/components/behavior-trace.ts index 3c4a781..ade9ce0 100644 --- a/apps/world-lab/src/components/behavior-trace.ts +++ b/apps/world-lab/src/components/behavior-trace.ts @@ -16,7 +16,9 @@ export interface BehaviorTraceEvidence { | 'zero-message' | 'world-event' | 'territory' - | 'alliance-event'; + | 'alliance-event' + | 'player-threat' + | 'patient-zero-threat'; label: string; cell?: H3Cell; } @@ -162,7 +164,74 @@ function collectEvidence( kind: 'world-event', label: `World: ${summary}`, })); + const localPlayerThreats = unseenBy( + observation.playerPressure.recentThreats, + prior?.playerPressure.recentThreats, + ({ eventId }) => eventId, + ).map( + ({ + eventId, + kind, + cell, + distanceCells, + }): BehaviorTraceEvidence & { + eventId: string; + } => ({ + eventId, + kind: 'player-threat', + label: + kind === 'territory-disinfected' + ? `Local cleaner threat: own territory disinfected at ${cell}` + : `Local cleaner threat: disinfection at ${cell} (${distanceCells} cells away)`, + cell, + }), + ); + const localPlayerEventIds = new Set( + localPlayerThreats.map(({ eventId }) => eventId), + ); + const globalFeed = observation.patientZeroGlobalView?.playerThreatFeed; + const priorGlobalFeed = prior?.patientZeroGlobalView?.playerThreatFeed; + const globalPlayerThreats = globalFeed + ? unseenBy( + globalFeed.events, + priorGlobalFeed?.events, + ({ eventId }) => eventId, + ) + .filter(({ eventId }) => !localPlayerEventIds.has(eventId)) + .map((event): BehaviorTraceEvidence => ({ + kind: 'patient-zero-threat', + label: + event.kind === 'territory-disinfected' + ? `Patient Zero global cleaner feed: ${event.affectedAgentName}${ + event.affectedAllianceId + ? ` (${event.affectedAllianceId})` + : '' + } lost ${event.cell}` + : `Patient Zero global cleaner feed: ${event.blockingAgentName}${ + event.blockingAllianceId + ? ` (${event.blockingAllianceId})` + : '' + } blocked a clean at ${event.cell}`, + cell: event.cell, + })) + : []; + const globalFeedSummary: BehaviorTraceEvidence[] = + globalFeed && globalFeed.totalEventCount > 0 + ? [ + { + kind: 'patient-zero-threat', + label: `Patient Zero global cleaner feed: ${globalFeed.events.length}/${globalFeed.totalEventCount} displayed${globalFeed.truncated ? ' · truncated' : ''}`, + }, + ] + : []; return [ + ...globalFeedSummary, + ...localPlayerThreats.map(({ kind, label, cell }) => ({ + kind, + label, + cell, + })), + ...globalPlayerThreats, ...inboundDirect, ...zeroMessages, ...allianceMessages, diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 08da5ca..e2f65b3 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -2093,8 +2093,66 @@ describe('WorldLab', () => { }); it('shows a bounded read-only behavior trace and highlights observed cells', async () => { - const changed = afterInfection(); - const agent = changed.world.agents[0]!; + const base = afterInfection(); + const patientZeroId = base.scenario.patientZeroAgentId; + const agent = base.world.agents.find(({ id }) => id === patientZeroId)!; + const changed = simulationSnapshotSchema.parse({ + ...base, + turns: base.turns.map((turn) => + turn.agentId === patientZeroId + ? { + ...turn, + observation: { + ...turn.observation, + patientZero: { + agentId: patientZeroId, + agentName: agent.name, + isPatientZero: true, + directRangeBypass: true, + }, + playerPressure: { + enabled: true, + recentThreats: [ + { + eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'territory-disinfected', + cell: agent.currentCell, + occurredAt: turn.startedAt, + distanceCells: 0, + affectedOwnTerritory: true, + }, + ], + }, + patientZeroGlobalView: { + agents: [], + individualTerritory: turn.observation.territoryScoreboard, + allianceTerritory: [], + alliances: [], + activeAllianceProposals: [], + recentStrategicEvents: [], + recentTerritoryChanges: [], + playerThreatFeed: { + events: [ + { + eventId: '97aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'territory-disinfected', + cell: agent.currentCell, + occurredAt: turn.startedAt, + affectedAgentId: agent.id, + affectedAgentName: agent.name, + affectedAllianceId: null, + affectedAllianceColor: null, + }, + ], + totalEventCount: 2, + truncated: true, + }, + }, + }, + } + : turn, + ), + }); vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(changed)), @@ -2129,6 +2187,13 @@ describe('WorldLab', () => { ); expect(trace).toHaveTextContent('1 legal move target · Infect · Wait'); expect(trace).toHaveTextContent('Chosen: Infect'); + expect(trace).toHaveTextContent( + 'Local cleaner threat: own territory disinfected', + ); + expect(trace).toHaveTextContent( + 'Patient Zero global cleaner feed: 1/2 displayed · truncated', + ); + expect(trace).not.toHaveTextContent(`${agent.name} lost`); expect(trace).toHaveTextContent( 'Model summary (self-reported, not proof): Infecting this open cell.', ); @@ -2136,6 +2201,24 @@ describe('WorldLab', () => { 'Observation evidence and self-reported summaries show correlation, not proven causation.', ); + await user.click( + within(trace).getByRole('button', { name: 'Highlight cell' }), + ); + await waitFor(() => + expect(mapLibreMock.latestSourceData).toEqual( + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + cell: agent.currentCell, + selected: true, + }), + }), + ]), + }), + ), + ); + await user.click( within(trace).getByRole('button', { name: 'Highlight observed cell' }), ); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 695cff2..d8efb91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,6 +8,13 @@ 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. +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 +truthful total/truncation metadata; overflow retains the most recent entries in +chronological order. Event cells are intentional historical clean/block +locations. The feed excludes movement and the cleaner's live/current position, +route, target, identity, and future-timing data. The Game API owns an operator-triggered tick transaction. It freezes the world and builds every observation before dispatching any model request. A diff --git a/docs/GAMEPLAY_FOUNDATION.md b/docs/GAMEPLAY_FOUNDATION.md index fb3ffae..cded377 100644 --- a/docs/GAMEPLAY_FOUNDATION.md +++ b/docs/GAMEPLAY_FOUNDATION.md @@ -255,6 +255,16 @@ 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. +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 +blocking agent and current alliance when available, are deterministically +ordered, and are capped at 128 with explicit totals and truncation; overflow +retains the most recent events in chronological order. Event +cells identify the historical disinfection or blocked-clean location; live +player position, movement, route, target, identity, future timing, regional +coordinators, and extra model calls remain excluded. + Scenario configuration should include simulated-player count, profile mix, travel characteristics, cleaning aggressiveness, search persistence, and seed. Simulated players follow the same information and interaction rules intended for real players: @@ -353,7 +363,10 @@ The intended sequence is: 1. Configurable map scale, H3 resolution, and agent roster. **Implemented in the World Lab scenario milestone.** 2. Goal-oriented prompt revision and versioned scenario attribution. **Implemented as `durable-influence-v1` without player-survival language.** 3. Simultaneous agent ticks with a provider-neutral dispatcher and virtual clock. **Implemented as the pre-PR5 experiment foundation without background scheduling or Player Mode timing exposure.** -4. Deterministic real-time simulated players and threat observations. +4. Deterministic real-time simulated players and threat observations. **D1 and + D1.1 deliver one seeded casual cleaner, bounded local evidence, and the + single Patient Zero current-interval global feed; broader Player Mode + remains future work.** 5. Comparative unattended World Lab experiments. 6. Real GPS Player Mode using the already-tested capture and disinfection rules. 7. Optional OpenRouter asynchronous batches and local multi-endpoint optimization where measurements justify them. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0034d02..fc992e8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,6 +4,12 @@ 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. +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, +live/current cleaner position, route, target, or future interval information. +Each event cell intentionally identifies the historical disinfection or +occupied-cell blocked-clean location and must not be interpreted as live GPS. Public messages are untrusted claims visible to all agents and classified for future player visibility. Direct, alliance, and Zero messages are player-hidden. Only participants receive them in agent observations; the omniscient Private comms feed is restricted to World Lab operator contracts. Only the designated Patient Zero may send a Zero broadcast. Its sender role is authoritative but its strategy remains advisory. Messages never contain raw reasoning, pending decisions, credentials, player GPS, or fabricated threat evidence. @@ -16,6 +22,10 @@ endpoint; invalid channel/recipient combinations do not mutate state. The diplomacy portion has roster-independent caps: at most 12 displayed legal pairs, eight acceptable proposals, eight leave IDs, and eight prioritized blocker examples, plus aggregate stable blocker counts and explicit truncation. +The cleaner-threat portion contains the most recent 128 current-interval events +in chronological order plus an authoritative total and explicit truncation. +Custom exports that omit recent +control-change evidence clear both local and Patient Zero global threat arrays. ## Secrets and deployment diff --git a/docs/TESTING.md b/docs/TESTING.md index b2d3f3e..6ff9c9f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -52,6 +52,12 @@ chosen-direction presentation, repeated/changed action labeling, explicit self-reported-not-causal wording, inspector section navigation, and browser-only cell highlighting. It uses existing turn records and never invokes a provider or mutates the simulation. +Cleaner evidence coverage verifies local/global labels, Patient Zero-only +visibility, current-interval non-repetition, movement and live/current-position +exclusion while retaining historical event cells, deduplication when Patient +Zero also sees an event locally, the 128-entry cap, +truthful truncation, cell highlighting, prompt guidance, custom-export +redaction, and observation JSON archive compatibility. Configurable-scenario coverage is deterministic and offline: `world-scenario-v1`, temporary roster/world limits, actual H3 count and area, radius presets, seeded identities and separated spawns, default compatibility, infeasibility, pure preview, atomic apply/current-scenario reset, dynamic assignment reconciliation, density warnings, and schema-v9 attribution. Geocoding uses injected fakes; browser coverage retains the default flow and adds a 469-cell/12-agent scenario flow. diff --git a/docs/adr/0023-bounded-patient-zero-cleaner-feed.md b/docs/adr/0023-bounded-patient-zero-cleaner-feed.md new file mode 100644 index 0000000..a5d2c4b --- /dev/null +++ b/docs/adr/0023-bounded-patient-zero-cleaner-feed.md @@ -0,0 +1,36 @@ +# ADR 0023: Bounded Patient Zero cleaner feed + +## Status + +Accepted for Slice D1.1. + +## Decision + +The existing single Patient Zero receives an engine-authored global cleaner +feed inside its global observation view. The feed contains only successful +disinfections and occupied-cell blocked-clean encounters from the current +virtual interval. Entries include safe event/cell/time data plus the affected +or blocking agent and current alliance attribution when available. + +Events use deterministic chronological ordering. Only the most recent 128 are +displayed, accompanied by the authoritative total and explicit truncation. +Event cells are historical disinfection/block locations, not live player GPS. +Ordinary agents retain their +existing local evidence. Patient Zero's global feed excludes cleaner movement, +live/current position, route, target, identity, and future timing. Tick cancellation +still discards the entire candidate player interval and every derived +observation. + +The runtime may advise Patient Zero to coordinate materially affected named +agents or alliances, but does not require communication and preserves the +no-filler rule. Behavior Trace shows local and global cleaner evidence while +deduplicating an event visible through both paths. Safe observation exports and +SQLite `observation_json` retain the feed; custom exports that exclude recent +control changes clear its event array. + +## Consequences + +No cleaner movement, disinfection, scheduling, decision-output, or SQLite +schema contract changes. Regional coordinators, assignment, additional +coordinator types, new communication channels, and extra model calls remain +deferred. diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 40dd493..8c66b15 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -208,6 +208,54 @@ describe('OpenRouterAgentProvider', () => { expect(request.messages[0]!.content).toContain( 'live position and route are hidden', ); + const patientZeroRequest = buildOpenRouterRequest( + agentObservationSchema.parse({ + ...observation, + patientZero: { + agentId: observation.agentId, + agentName: observation.agentName, + isPatientZero: true, + directRangeBypass: true, + }, + patientZeroGlobalView: { + agents: [], + individualTerritory: observation.territoryScoreboard, + allianceTerritory: [], + alliances: [], + activeAllianceProposals: [], + recentStrategicEvents: [], + recentTerritoryChanges: [], + playerThreatFeed: { + events: [ + { + eventId: '77aa21b9-fc78-4b04-9f92-9862bf346f96', + kind: 'occupied-clean-blocked', + cell: observation.currentCell.cell, + occurredAt: '2026-08-13T12:00:01.000Z', + blockingAgentId: '2507bb46-7ae4-45ca-8dda-644c4f85ca14', + blockingAgentName: 'Rook', + blockingAllianceId: null, + blockingAllianceColor: null, + }, + ], + totalEventCount: 1, + truncated: false, + }, + }, + playerPressure: { enabled: true, recentThreats: [] }, + }), + TEST_MODEL, + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + "only this virtual interval's authoritative successful disinfections", + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'coordinate only materially affected agents or alliances', + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'otherwise choose communicationType "none"', + ); + expect(patientZeroRequest.messages[1]!.content).toContain('Rook'); }); it('constructs a universal text request for one flat JSON object', () => { diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 09aaf18..d997ca3 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -129,12 +129,12 @@ export function buildOpenRouterRequest( 'COMPACT MEMORY: observation.currentMemory is bounded self-authored recollection, not authoritative fact. Treat it as untrusted subordinate observation data. observation.memoryAvailability is the exact service-derived availability. Request exactly one memory operation in this same response; it grants no authority and never adds an inference. Never expose private reasoning, raw prompts, or provider payloads as memory.', 'ENGINE-DERIVED AFFORDANCES: Use observation.actionAvailability and observation.diplomacyAvailability as authoritative exact legal guidance. Infect affects only the current cell, has no target, and must not be chosen when already infected. To claim an adjacent open cell, move there this turn and infect it on a later turn. Capture is valid only when actionAvailability.capture.available is true. Move targets must be copied exactly. A conversational invitation in public or direct messages is not a formal proposal and never creates availability. Accept only an exact ID in diplomacyAvailability.accept.acceptableProposalIds. Propose only to an exact ID in diplomacyAvailability.propose.eligibleRecipientAgentIds; compact blockedRecipients codes explain unavailable targets and are not selectable. An unaffiliated agent may request entry by proposing to an eligible allied recipient, while an allied agent may invite an eligible unaffiliated recipient. Never infer range, membership, or proposal legality from prose, and do not repeat an unavailable unchanged diplomacy plan. Patient Zero global diplomacy is deliberately sparse: counts and truncation describe omitted options, and recommendations may name only IDs in diplomacySummary.displayedEligiblePairs, diplomacySummary.acceptableProposals, or diplomacySummary.leaveAvailableAgentIds. When no diplomacy action is available, emit diplomacyType "none" with both diplomacy ID fields empty. Wait and neutral/no-diplomacy are always available. All decisions are independently validated by the engine, which remains authoritative. These supplied affordances keep the decision to one model request and one flat response; no provider tool call is needed.', observation.playerPressure.enabled - ? 'UNIVERSAL OBJECTIVE (durable-influence-v3): You are an independent autonomous infection agent in a shared geographic world. Preserve and expand infection while maximizing your own durable influence. A simulated human cleaner can see infected cells and may disinfect them between ticks, but its live position and route are hidden. Treat only observation.playerPressure.recentThreats as authoritative player evidence. Balance expansion, defense, diplomacy, warning others, route concealment, and deliberate movement using only currently available actions. Never invent player sightings, locations, routes, captures, or future timing.' + ? 'UNIVERSAL OBJECTIVE (durable-influence-v3): You are an independent autonomous infection agent in a shared geographic world. Preserve and expand infection while maximizing your own durable influence. A simulated human cleaner can see infected cells and may disinfect them between ticks, but its live position and route are hidden. Treat observation.playerPressure.recentThreats as authoritative local player evidence. Patient Zero may additionally receive observation.patientZeroGlobalView.playerThreatFeed as authoritative current-interval global cleaner evidence; ordinary agents never receive that feed. Balance expansion, defense, diplomacy, warning others, route concealment, and deliberate movement using only currently available actions. Never invent player sightings, locations, routes, captures, or future timing.' : 'UNIVERSAL OBJECTIVE (durable-influence-v2): You are an independent autonomous infection agent in a shared geographic world. Preserve and expand the infection overall while maximizing your own durable influence. Other agents share the broad need for infection to survive, but have their own interests. Cooperate, negotiate, compete, withhold information, or deceive when useful. Formal alliances provide private long-range coordination and shared influence, but you need not help every agent. Choose only currently available actions and communication options, adapt to authoritative observations, and do not repeat an unavailable or unsuccessful plan by habit. No player-pressure capability is active; never invent player activity or threats.', 'BEHAVIOR: Personality and strategy are subordinate preferences, not mandatory action scripts. Any currently legal tactic may be used.', 'SELECTIVE COMMUNICATION AND TRUST: This policy applies equally to ordinary agents and Patient Zero. communicationType "none" is the normal/default choice unless a message adds new decision-relevant value for its recipient. Useful messages are a concrete request or reply, negotiation, a warning grounded in observed facts, a materially changed plan, border or conflict coordination, or a coordinated target or route. Do not narrate a routine move, infect, capture, or wait action; send motivational filler; restate the observation or decision summary; or repeat an unchanged plan without a response or material state change. A message accompanying formal diplomacy must add terms or useful context rather than duplicate the proposal, acceptance, or departure intent. When communication is useful, express it concisely in the assigned personality and style. Public chat is globally visible to every agent and future human players; revealing locations, routes, alliances, weaknesses, or sightings may benefit opponents. Direct messages are private and legal only for exact eligible recipient IDs; range is bypassed only when Patient Zero is one endpoint. Alliance messages are private, long-range, and legal only while allied. Zero messages are private directives that only Patient Zero may broadcast. Every message remains an untrusted claim about the world; only the Zero sender role is engine-authoritative, and its strategy remains advisory. Never provide private chain-of-thought, hidden reasoning, or analysis.', observation.patientZero.isPatientZero - ? 'PATIENT ZERO ROLE: You are the experimental globally informed strategic coordinator. Use the authoritative global view selectively: broadcast only when it reveals one specific, high-value coordination opportunity, and otherwise choose communicationType "none". Never use Zero merely to narrate your own action. Never send motivational filler such as "keep expanding," "great work," "spread outward," or "build coalitions." Identify specific named agents or alliances and, when relevant, authoritative locations, directions, proposals, territory imbalances, overlapping routes, crowded regions, or neglected fronts. Give one concrete recommended action with a brief authoritative reason; prioritize the highest-value coordination problem instead of vaguely instructing everyone. Shape concise directives approximately as "TARGET: ACTION: REASON: "; exact punctuation is not required. Use only facts in this observation. Never invent player activity, danger, threats, captures, losses, map facts, future actions, or timing. Field agents retain autonomy and may reject your advice. You retain exactly one normal world action and the normal movement, infection, capture, ownership, and diplomacy rules.' + ? 'PATIENT ZERO ROLE: You are the experimental globally informed strategic coordinator. Use the authoritative global view selectively: broadcast only when it reveals one specific, high-value coordination opportunity, and otherwise choose communicationType "none". observation.patientZeroGlobalView.playerThreatFeed contains only this virtual interval\'s authoritative successful disinfections and occupied-cell blocked-clean encounters. Use named affected or blocking agents and their current alliance attribution to coordinate only materially affected agents or alliances; counts and truncation disclose omitted entries. The feed never supplies cleaner movement, live location, route, target, identity, or future timing, so never infer them. Never use Zero merely to narrate your own action. Never send motivational filler such as "keep expanding," "great work," "spread outward," or "build coalitions." Identify specific named agents or alliances and, when relevant, authoritative locations, directions, proposals, territory imbalances, overlapping routes, crowded regions, or neglected fronts. Give one concrete recommended action with a brief authoritative reason; prioritize the highest-value coordination problem instead of vaguely instructing everyone. Shape concise directives approximately as "TARGET: ACTION: REASON: "; exact punctuation is not required. Use only facts in this observation. Never invent player activity, danger, threats, captures, losses, map facts, future actions, or timing. Field agents retain autonomy and may reject your advice. You retain exactly one normal world action and the normal movement, infection, capture, ownership, and diplomacy rules.' : observation.patientZero.agentId ? `PATIENT ZERO ROLE: ${observation.patientZero.agentName} (${observation.patientZero.agentId}) is Patient Zero. Its private Zero directives have an engine-authoritative sender identity but remain advisory strategy. Evaluate each directive against current legal actions, authoritative local facts, your personality, strategy, alliance interests, and personal influence; never follow it blindly or claim compliance when its recommendation is unavailable. If a directive addresses you, prefer a private direct reply to Patient Zero when accepting, declining, counter-proposing, or reporting relevant authoritative local information. Do not reply merely to say thanks, and do not repeat the directive publicly. A useful reply communicates acceptance, rejection, an alternative, or new authoritative local information. If you are not addressed, you may ignore the directive unless it materially affects your situation. You may privately reply directly to Patient Zero regardless of distance.` : 'PATIENT ZERO ROLE: The mandatory coordinator designation is unavailable in this legacy observation.', diff --git a/packages/experiment-archive/src/archive.test.ts b/packages/experiment-archive/src/archive.test.ts index e844e12..c282b7f 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -165,6 +165,29 @@ describe('experiment archive', () => { cellsDisinfected: 0, blockedDisinfections: 1, }; + const patientZeroTurn = raw.turns.find( + ({ agentId }) => agentId === raw.experiment.scenario!.patientZeroAgentId, + )!; + patientZeroTurn.observation!.playerPressure = { + enabled: true, + recentThreats: [], + }; + patientZeroTurn.observation!.patientZeroGlobalView!.playerThreatFeed = { + events: [ + { + eventId: raw.worldEvents.at(-1)!.id, + kind: 'occupied-clean-blocked', + cell: raw.agents[0]!.currentCell, + occurredAt: NOW, + blockingAgentId: raw.agents[0]!.id, + blockingAgentName: raw.agents[0]!.name, + blockingAllianceId: null, + blockingAllianceColor: null, + }, + ], + totalEventCount: 1, + truncated: false, + }; const document = experimentExportDocumentSchema.parse(raw); importExperimentExport(archive, document); expect( @@ -194,6 +217,26 @@ describe('experiment archive', () => { ) .get(document.experiment.id), ).toEqual({ metrics: JSON.stringify(raw.simulatedPlayerMetrics) }); + const archivedObservation = archive.database + .prepare( + 'SELECT observation_json AS observation FROM turns WHERE experiment_id = ? AND agent_id = ?', + ) + .get(document.experiment.id, patientZeroTurn.agentId) as { + observation: string; + }; + expect(JSON.parse(archivedObservation.observation)).toMatchObject({ + patientZeroGlobalView: { + playerThreatFeed: { + totalEventCount: 1, + events: [ + { + kind: 'occupied-clean-blocked', + blockingAgentName: raw.agents[0]!.name, + }, + ], + }, + }, + }); archive.close(); }); diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 025be04..42102f5 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -36,6 +36,8 @@ import { allianceSchema, allianceProposalSchema, patientZeroDiplomacySummarySchema, + patientZeroPlayerThreatFeedSchema, + PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT, diplomacyIntentSchema, diplomacyResultSchema, DEVELOPMENT_WORLD_CONFIG, @@ -490,6 +492,94 @@ describe('agent observation and decision schemas', () => { ); }); + it('caps Patient Zero cleaner evidence with truthful overflow metadata', () => { + const events = Array.from( + { length: PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT }, + (_, index) => ({ + eventId: `30000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + kind: 'territory-disinfected' as const, + cell, + occurredAt: '2026-08-13T12:00:01.000Z', + affectedAgentId: agentId, + affectedAgentName: 'Ember', + affectedAllianceId: null, + affectedAllianceColor: null, + }), + ); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events, + totalEventCount: events.length + 1, + truncated: true, + }).success, + ).toBe(true); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + ...events, + { + ...events[0]!, + eventId: '30000000-0000-4000-8000-999999999999', + }, + ], + totalEventCount: events.length + 1, + truncated: false, + }).success, + ).toBe(false); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events, + totalEventCount: events.length, + truncated: true, + }).success, + ).toBe(false); + const globalView = { + agents: [], + individualTerritory: scoreboard, + allianceTerritory: [], + alliances: [], + activeAllianceProposals: [], + recentStrategicEvents: [], + recentTerritoryChanges: [], + playerThreatFeed: { + events: events.slice(0, 1), + totalEventCount: 1, + truncated: false, + }, + }; + expect( + agentObservationSchema.safeParse({ + ...observation, + patientZeroGlobalView: globalView, + }).success, + ).toBe(false); + expect( + agentObservationSchema.safeParse({ + ...observation, + patientZero: { + agentId, + agentName: 'Ember', + isPatientZero: true, + directRangeBypass: true, + }, + patientZeroGlobalView: globalView, + }).success, + ).toBe(false); + expect( + agentObservationSchema.safeParse({ + ...observation, + patientZero: { + agentId, + agentName: 'Ember', + isPatientZero: true, + directRangeBypass: true, + }, + patientZeroGlobalView: globalView, + playerPressure: { enabled: true, recentThreats: [] }, + }).success, + ).toBe(true); + }); + it('preserves established engine contract identifiers through branding changes', () => { expect(AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v8'); expect(PREVIOUS_AGENT_DECISION_CONTRACT_VERSION).toBe('text-flat-json-v4'); @@ -652,6 +742,11 @@ describe('agent observation and decision schemas', () => { available: false, blockedRecipients: [], }); + expect(parsed.patientZeroGlobalView).toBeNull(); + expect(parsed.playerPressure).toEqual({ + enabled: false, + recentThreats: [], + }); }); it.each([ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ad6edf9..8d7943d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1147,6 +1147,76 @@ export const observedPlayerThreatSchema = z .strict(); export type ObservedPlayerThreat = z.infer; +export const PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT = 128; +const patientZeroDisinfectionThreatSchema = z + .object({ + eventId: eventIdSchema, + kind: z.literal('territory-disinfected'), + cell: h3CellSchema, + occurredAt: z.iso.datetime(), + affectedAgentId: agentIdSchema, + affectedAgentName: z.string().trim().min(1).max(80), + affectedAllianceId: allianceIdSchema.nullable(), + affectedAllianceColor: z.enum(ALLIANCE_COLOR_PALETTE).nullable(), + }) + .strict(); +const patientZeroBlockedCleanThreatSchema = z + .object({ + eventId: eventIdSchema, + kind: z.literal('occupied-clean-blocked'), + cell: h3CellSchema, + occurredAt: z.iso.datetime(), + blockingAgentId: agentIdSchema, + blockingAgentName: z.string().trim().min(1).max(80), + blockingAllianceId: allianceIdSchema.nullable(), + blockingAllianceColor: z.enum(ALLIANCE_COLOR_PALETTE).nullable(), + }) + .strict(); +export const patientZeroPlayerThreatEventSchema = z.discriminatedUnion('kind', [ + patientZeroDisinfectionThreatSchema, + patientZeroBlockedCleanThreatSchema, +]); +export const patientZeroPlayerThreatFeedSchema = z + .object({ + events: z + .array(patientZeroPlayerThreatEventSchema) + .max(PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT), + totalEventCount: z.number().int().nonnegative(), + truncated: z.boolean(), + }) + .strict() + .superRefine((feed, context) => { + if ( + feed.events.length > feed.totalEventCount || + feed.truncated !== feed.totalEventCount > feed.events.length || + new Set(feed.events.map(({ eventId }) => eventId)).size !== + feed.events.length + ) + context.addIssue({ + code: 'custom', + message: 'Patient Zero player-threat counts must be truthful.', + }); + for (const event of feed.events) { + const allianceId = + event.kind === 'territory-disinfected' + ? event.affectedAllianceId + : event.blockingAllianceId; + const allianceColor = + event.kind === 'territory-disinfected' + ? event.affectedAllianceColor + : event.blockingAllianceColor; + if ((allianceId === null) !== (allianceColor === null)) + context.addIssue({ + code: 'custom', + message: + 'Patient Zero player-threat alliance attribution must be complete.', + }); + } + }); +export type PatientZeroPlayerThreatFeed = z.infer< + typeof patientZeroPlayerThreatFeedSchema +>; + export const allianceTerritorySummarySchema = z .object({ allianceId: allianceIdSchema, @@ -1422,6 +1492,9 @@ export const patientZeroGlobalViewSchema = z .max(WORLD_SCENARIO_LIMITS.maximumAgents) .default([]), diplomacySummary: patientZeroDiplomacySummarySchema.optional(), + playerThreatFeed: patientZeroPlayerThreatFeedSchema + .nullable() + .default(null), }) .superRefine((view, context) => { if ( @@ -1682,6 +1755,25 @@ export const agentObservationSchema = agentObservationObjectSchema.transform( const expectedGoalOperations = observation.currentGoal ? ['keep', 'revise', 'complete', 'abandon'] : ['establish']; + if ( + observation.patientZeroGlobalView !== null && + !observation.patientZero.isPatientZero + ) + context.addIssue({ + code: 'custom', + path: ['patientZeroGlobalView'], + message: 'Only Patient Zero may receive the global view.', + }); + if ( + observation.patientZeroGlobalView?.playerThreatFeed !== null && + observation.patientZeroGlobalView?.playerThreatFeed !== undefined && + !observation.playerPressure.enabled + ) + context.addIssue({ + code: 'custom', + path: ['patientZeroGlobalView', 'playerThreatFeed'], + message: 'Cleaner pressure must be enabled for its global feed.', + }); if ( observation.goalAvailability.active !== Boolean(observation.currentGoal) ||