From 167dd4500f3744bd8dcb1d39a21becf54338ebfd Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Mon, 24 Aug 2026 14:49:26 -0400 Subject: [PATCH] feat: add patient zero pressure rollup --- apps/game-api/src/simulation-service.test.ts | 83 ++++++++++++ apps/game-api/src/simulation-service.ts | 95 ++++++++++++- .../src/components/behavior-trace.test.ts | 24 ++++ .../src/components/behavior-trace.ts | 20 ++- .../src/components/world-lab.test.tsx | 13 ++ docs/ARCHITECTURE.md | 10 ++ docs/GAMEPLAY_FOUNDATION.md | 10 ++ docs/SECURITY.md | 5 + docs/TESTING.md | 5 + ...4-bounded-patient-zero-pressure-context.md | 36 +++++ packages/agent-runtime/src/index.test.ts | 28 +++- packages/agent-runtime/src/index.ts | 2 +- .../experiment-archive/src/archive.test.ts | 17 +++ packages/shared/src/index.test.ts | 128 ++++++++++++++++++ packages/shared/src/index.ts | 96 +++++++++++++ 15 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0024-bounded-patient-zero-pressure-context.md diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 003157b..70fe344 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -18,6 +18,7 @@ import { agentTurnRecordSchema, experimentExportDocumentSchema, h3CellSchema, + worldEventSchema, memoryIdSchema, type Alliance, type AgentId, @@ -42,6 +43,7 @@ import { SimulationValidationError, selectDiplomacyBlockerExamples, selectMostRecentPatientZeroThreats, + calculatePatientZeroPressureContext, applyGoalRevision, applyMemoryOperation, } from './simulation-service'; @@ -114,6 +116,76 @@ describe('SimulationService', () => { ); }); + it('calculates a truthful six-tick pressure window despite unrelated event churn', () => { + const subject = agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e'); + const ally = agentIdSchema.parse('2507bb46-7ae4-45ca-8dda-644c4f85ca14'); + const makeEvent = ( + tick: number, + type: 'hex-disinfected' | 'simulated-player-clean-blocked', + agentId: AgentId, + ordinal: number, + ): WorldEvent => + worldEventSchema.parse({ + id: `30000000-0000-4000-8000-${String(ordinal).padStart(12, '0')}`, + type, + occurredAt: new Date( + new Date('2026-08-23T12:00:00.000Z').getTime() + ordinal, + ).toISOString(), + profile: 'casual-cleaner', + originatingTick: tick, + cell: '892a1072893ffff', + ...(type === 'hex-disinfected' + ? { previousControllerAgentId: agentId } + : { blockingAgentId: agentId }), + }); + const events: WorldEvent[] = [ + makeEvent(2, 'hex-disinfected', subject, 1), + makeEvent(3, 'simulated-player-clean-blocked', ally, 2), + makeEvent(4, 'hex-disinfected', subject, 3), + makeEvent(6, 'simulated-player-clean-blocked', subject, 4), + makeEvent(7, 'hex-disinfected', subject, 5), + makeEvent(8, 'simulated-player-clean-blocked', subject, 6), + makeEvent(8, 'hex-disinfected', ally, 7), + makeEvent(9, 'hex-disinfected', subject, 9), + worldEventSchema.parse({ + id: '30000000-0000-4000-8000-000000000008', + type: 'simulated-player-moved', + occurredAt: '2026-08-23T12:00:00.008Z', + profile: 'casual-cleaner', + originatingTick: 8, + fromCell: '892a1072893ffff', + toCell: '892a1072883ffff', + }), + ...Array.from({ length: 160 }, (_, index) => + worldEventSchema.parse({ + id: `40000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + type: 'agent-waited', + occurredAt: new Date( + new Date('2026-08-23T12:01:00.000Z').getTime() + index, + ).toISOString(), + agentId: subject, + }), + ), + ]; + + expect( + calculatePatientZeroPressureContext(events, subject, [subject, ally], 8), + ).toEqual({ + window: { tickCount: 6, startTick: 3, endTick: 8 }, + subject: { + totalEvents: 4, + disinfections: 2, + blockedCleans: 2, + consecutiveAffectedTicks: 3, + }, + currentAlliance: { + totalEvents: 6, + disinfections: 3, + blockedCleans: 3, + }, + }); + }); + it('commits cleaner pressure before frozen observations without exposing live GPS', async () => { const seen: AgentObservation[] = []; const simulation = service({ @@ -225,6 +297,14 @@ describe('SimulationService', () => { }), ]), ); + expect( + patientZeroObservations + .flatMap( + ({ patientZeroGlobalView }) => + patientZeroGlobalView!.playerThreatFeed!.events, + ) + .every(({ pressureContext }) => pressureContext !== undefined), + ).toBe(true); expect( JSON.stringify( patientZeroObservations.map( @@ -275,6 +355,9 @@ describe('SimulationService', () => { feed.truncated === feed.totalEventCount > 0, ), ).toBe(true); + expect(JSON.stringify(redactedGlobalFeeds)).not.toContain( + 'pressureContext', + ); }); it('keeps compact memory canonical and rejects full or missing operations independently', () => { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 36a8390..b8164d2 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -35,6 +35,7 @@ import { WORLD_SCENARIO_LIMITS, PATIENT_ZERO_DIPLOMACY_SUMMARY_LIMITS, PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT, + PATIENT_ZERO_PRESSURE_WINDOW_TICKS, MEMORY_ENTRY_LIMIT, personalitySchema, simulationSnapshotSchema, @@ -67,6 +68,7 @@ import { type AllianceEvent, type AllianceProposalId, type SimulatedPlayerEvent, + type PatientZeroPressureContext, worldSetupRequestSchema, type AppliedScenario, type WorldSetupPreviewResponse, @@ -118,6 +120,78 @@ export function selectMostRecentPatientZeroThreats< .slice(-PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT); } +export function calculatePatientZeroPressureContext( + events: readonly WorldEvent[], + subjectAgentId: AgentId, + currentAllianceMemberIds: readonly AgentId[] | null, + currentTick: number, +): PatientZeroPressureContext { + const startTick = Math.max( + 1, + currentTick - PATIENT_ZERO_PRESSURE_WINDOW_TICKS + 1, + ); + const relevant = 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 >= startTick && + event.originatingTick <= currentTick, + ); + const eventSubject = (event: (typeof relevant)[number]): AgentId => + event.type === 'hex-disinfected' + ? event.previousControllerAgentId + : event.blockingAgentId; + const countsFor = (selected: readonly (typeof relevant)[number][]) => ({ + totalEvents: selected.length, + disinfections: selected.filter(({ type }) => type === 'hex-disinfected') + .length, + blockedCleans: selected.filter( + ({ type }) => type === 'simulated-player-clean-blocked', + ).length, + }); + const subjectEvents = relevant.filter( + (event) => eventSubject(event) === subjectAgentId, + ); + const subjectTicks = new Set( + subjectEvents.map(({ originatingTick }) => originatingTick), + ); + if (!subjectTicks.has(currentTick)) + throw new Error( + 'Patient Zero pressure context requires a current subject event.', + ); + let consecutiveAffectedTicks = 0; + for ( + let tick = currentTick; + tick >= startTick && subjectTicks.has(tick); + tick -= 1 + ) + consecutiveAffectedTicks += 1; + const memberIds = currentAllianceMemberIds + ? new Set(currentAllianceMemberIds) + : null; + return { + window: { + tickCount: currentTick - startTick + 1, + startTick, + endTick: currentTick, + }, + subject: { + ...countsFor(subjectEvents), + consecutiveAffectedTicks, + }, + currentAlliance: memberIds + ? countsFor( + relevant.filter((event) => memberIds.has(eventSubject(event))), + ) + : null, + }; +} + interface PendingFailedTurn { turnNumber: number; agentId: AgentId; @@ -992,7 +1066,7 @@ export class SimulationService { observations = new Map( agents.map(({ id }) => [ id, - structuredClone(this.#buildObservation(id)), + structuredClone(this.#buildObservation(id, playerAdvance.events)), ]), ); } finally { @@ -1842,7 +1916,10 @@ export class SimulationService { }; } - #buildObservation(agentId: AgentId): AgentObservation { + #buildObservation( + agentId: AgentId, + currentCandidatePlayerEvents: readonly SimulatedPlayerEvent[] = [], + ): AgentObservation { const agent = this.#state.agents.get(agentId); if (!agent) throw new Error('The active agent does not exist.'); const currentGoal = this.#agentGoals.get(agentId) ?? null; @@ -2103,9 +2180,13 @@ export class SimulationService { affectedOwnTerritory, })) : []; + const completePlayerPressureEvents = [ + ...this.#simulatedPlayerEvents, + ...currentCandidatePlayerEvents, + ]; const patientZeroPlayerThreats = this.#scenario.capabilities .simulatedPlayerPressure - ? this.#state.events + ? currentCandidatePlayerEvents .filter( ( event, @@ -2133,6 +2214,12 @@ export class SimulationService { 'A simulated-player threat references an unknown agent.', ); const alliance = getAgentAlliance(this.#state, referencedAgentId); + const pressureContext = calculatePatientZeroPressureContext( + completePlayerPressureEvents, + referencedAgentId, + alliance?.memberAgentIds ?? null, + this.#completedTickCount + 1, + ); return event.type === 'hex-disinfected' ? { eventId: event.id, @@ -2143,6 +2230,7 @@ export class SimulationService { affectedAgentName: referencedAgent.name, affectedAllianceId: alliance?.id ?? null, affectedAllianceColor: alliance?.color ?? null, + pressureContext, } : { eventId: event.id, @@ -2153,6 +2241,7 @@ export class SimulationService { blockingAgentName: referencedAgent.name, blockingAllianceId: alliance?.id ?? null, blockingAllianceColor: alliance?.color ?? null, + pressureContext, }; }) : []; diff --git a/apps/world-lab/src/components/behavior-trace.test.ts b/apps/world-lab/src/components/behavior-trace.test.ts index 78ddb8b..2c487d1 100644 --- a/apps/world-lab/src/components/behavior-trace.test.ts +++ b/apps/world-lab/src/components/behavior-trace.test.ts @@ -152,6 +152,16 @@ function acceptedTurn( affectedAgentName: 'Ember', affectedAllianceId: null, affectedAllianceColor: null, + pressureContext: { + window: { tickCount: 6, startTick: 3, endTick: 8 }, + subject: { + totalEvents: 3, + disinfections: 2, + blockedCleans: 1, + consecutiveAffectedTicks: 2, + }, + currentAlliance: null, + }, }, { eventId: 'a7aa21b9-fc78-4b04-9f92-9862bf346f96', @@ -162,6 +172,16 @@ function acceptedTurn( blockingAgentName: 'Rook', blockingAllianceId: null, blockingAllianceColor: null, + pressureContext: { + window: { tickCount: 6, startTick: 3, endTick: 8 }, + subject: { + totalEvents: 2, + disinfections: 0, + blockedCleans: 2, + consecutiveAffectedTicks: 2, + }, + currentAlliance: null, + }, }, ], totalEventCount: 3, @@ -349,5 +369,9 @@ describe('deriveBehaviorTrace', () => { expect( evidence.filter(({ label }) => label.includes('Ember lost')), ).toHaveLength(0); + expect(evidence[1]!.label).toContain( + 'subject 3 total (2 disinfected, 1 blocked), 2 consecutive', + ); + expect(evidence[1]!.label).toContain('current alliance unaffiliated'); }); }); diff --git a/apps/world-lab/src/components/behavior-trace.ts b/apps/world-lab/src/components/behavior-trace.ts index ade9ce0..35a9569 100644 --- a/apps/world-lab/src/components/behavior-trace.ts +++ b/apps/world-lab/src/components/behavior-trace.ts @@ -191,6 +191,16 @@ function collectEvidence( ); const globalFeed = observation.patientZeroGlobalView?.playerThreatFeed; const priorGlobalFeed = prior?.patientZeroGlobalView?.playerThreatFeed; + const pressureSuffix = ( + event: NonNullable['events'][number] | undefined, + ): string => { + const pressure = event?.pressureContext; + if (!pressure) return ''; + const alliance = pressure.currentAlliance + ? `; current alliance ${pressure.currentAlliance.totalEvents} total (${pressure.currentAlliance.disinfections} disinfected, ${pressure.currentAlliance.blockedCleans} blocked)` + : '; current alliance unaffiliated'; + return ` · ticks ${pressure.window.startTick}–${pressure.window.endTick}: subject ${pressure.subject.totalEvents} total (${pressure.subject.disinfections} disinfected, ${pressure.subject.blockedCleans} blocked), ${pressure.subject.consecutiveAffectedTicks} consecutive${alliance}`; + }; const globalPlayerThreats = globalFeed ? unseenBy( globalFeed.events, @@ -206,12 +216,12 @@ function collectEvidence( event.affectedAllianceId ? ` (${event.affectedAllianceId})` : '' - } lost ${event.cell}` + } lost ${event.cell}${pressureSuffix(event)}` : `Patient Zero global cleaner feed: ${event.blockingAgentName}${ event.blockingAllianceId ? ` (${event.blockingAllianceId})` : '' - } blocked a clean at ${event.cell}`, + } blocked a clean at ${event.cell}${pressureSuffix(event)}`, cell: event.cell, })) : []; @@ -226,9 +236,11 @@ function collectEvidence( : []; return [ ...globalFeedSummary, - ...localPlayerThreats.map(({ kind, label, cell }) => ({ + ...localPlayerThreats.map(({ eventId, kind, label, cell }) => ({ kind, - label, + label: `${label}${pressureSuffix( + globalFeed?.events.find((event) => event.eventId === eventId), + )}`, cell, })), ...globalPlayerThreats, diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index e2f65b3..c13a9a0 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -2142,6 +2142,16 @@ describe('WorldLab', () => { affectedAgentName: agent.name, affectedAllianceId: null, affectedAllianceColor: null, + pressureContext: { + window: { tickCount: 2, startTick: 1, endTick: 2 }, + subject: { + totalEvents: 2, + disinfections: 1, + blockedCleans: 1, + consecutiveAffectedTicks: 2, + }, + currentAlliance: null, + }, }, ], totalEventCount: 2, @@ -2194,6 +2204,9 @@ describe('WorldLab', () => { 'Patient Zero global cleaner feed: 1/2 displayed · truncated', ); expect(trace).not.toHaveTextContent(`${agent.name} lost`); + expect(trace).toHaveTextContent( + 'subject 2 total (1 disinfected, 1 blocked), 2 consecutive', + ); expect(trace).toHaveTextContent( 'Model summary (self-reported, not proof): Infecting this open cell.', ); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8efb91..cc7934c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,6 +15,16 @@ 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. +Each displayed event carries a fixed six-tick, engine-derived pressure context +for its subject: subject totals/category counts/consecutive affected ticks and, +when currently allied, aggregate counts across the alliance's current members. +The event list remains current-interval-only; no historical event array or +historical membership inference is added. +The rollup reads committed prior intervals from the dedicated simulated-player +event history and combines them with only the current tick's uncommitted +candidate cleaner events. It does not depend on the smaller general world-event +display buffer, and candidate events enter dedicated history only when the whole +tick commits. 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 a3f36db..f7fe542 100644 --- a/docs/GAMEPLAY_FOUNDATION.md +++ b/docs/GAMEPLAY_FOUNDATION.md @@ -274,6 +274,16 @@ unchanged warnings, prefers named alliance reinforcement after sustained member pressure, and may remember a bounded meaningful pattern rather than every event. +D1.2 adds a compact six-tick rollup to each displayed current event so Patient +Zero can distinguish isolated from repeated subject pressure. It includes +subject event/category totals and consecutive affected ticks plus current-member +alliance totals when the subject is currently allied. The rollup includes the +current event, excludes movement and older events, and does not infer historical +alliance membership. An isolated event normally remains silent, although a +strategically meaningful first loss may justify one directive. Repeated subject +or current-alliance pressure strongly favors one new actionable directive after +checking recent Zero messages for equivalent unchanged advice. + 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: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fc992e8..11f714e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -26,6 +26,11 @@ 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. +Per-event pressure context is engine-derived from only the current and prior +five ticks. Alliance totals use current membership only. The context contains +counts and tick bounds, not historical event arrays, cleaner movement, live +position, or inferred historical membership; removing feed events also removes +their nested rollups. ## Secrets and deployment diff --git a/docs/TESTING.md b/docs/TESTING.md index 0b23fbe..1adf323 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -62,6 +62,11 @@ Runtime prompt tests also lock blocked-clean hold/reinforcement semantics, historical-cell and delayed-directive cautions, selective non-repeating communication, alliance-level sustained-pressure coordination, and bounded pattern memory. +D1.2 tests cover six-tick exclusion/inclusion, consecutive subject ticks, +current-member alliance aggregation, dishonest schema arithmetic/window/null +pairing, isolated-versus-sustained prompt selection, recent-Zero deduplication, +Behavior Trace count evidence, custom redaction, observation JSON archival, and +independence from unrelated general world-event history churn. 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/0024-bounded-patient-zero-pressure-context.md b/docs/adr/0024-bounded-patient-zero-pressure-context.md new file mode 100644 index 0000000..2e98802 --- /dev/null +++ b/docs/adr/0024-bounded-patient-zero-pressure-context.md @@ -0,0 +1,36 @@ +# ADR 0024: Bounded Patient Zero pressure context + +## Status + +Accepted for Slice D1.2. + +## Decision + +Every displayed current-interval Patient Zero cleaner event carries a compact +engine-authored six-tick pressure context for that event's affected or blocking +agent. The context reports window bounds, subject totals split by disinfection +and blocked clean, and consecutive affected ticks ending at the current tick. +When the subject is currently allied, it also reports the same totals across +the alliance's current members; unaffiliated subjects receive null alliance +counts. Current membership is used deliberately without reconstructing +historical membership. + +Only authoritative `hex-disinfected` and `simulated-player-clean-blocked` +events inside the window count. Movement and older events never count. The +current event must be represented. Runtime schemas enforce arithmetic, window, +minimum/current-event, consecutive, and alliance pairing constraints. Legacy +D1.1 events may omit the additive context, while the live service always emits +it for nonempty feeds. + +Patient Zero uses the rollup to distinguish isolated from sustained pressure. +A strategically meaningful first loss may justify a directive, while repeated +subject or current-alliance pressure strongly favors one new actionable +hold/reinforce/redundancy/reclaim/redirect recommendation. Equivalent recent +Zero advice suppresses repetition; communication is never mandatory. + +## Consequences + +The current-interval event cap, cleaner mechanics, observation audience, +provider count, decision contract, and SQLite schema do not change. Safe +observation JSON naturally retains nested context; custom redaction removes it +with the event. No broad historical feed or live cleaner location is introduced. diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 2b88086..bed6e93 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -236,6 +236,16 @@ describe('OpenRouterAgentProvider', () => { blockingAgentName: 'Rook', blockingAllianceId: null, blockingAllianceColor: null, + pressureContext: { + window: { tickCount: 6, startTick: 3, endTick: 8 }, + subject: { + totalEvents: 3, + disinfections: 1, + blockedCleans: 2, + consecutiveAffectedTicks: 2, + }, + currentAlliance: null, + }, }, ], totalEventCount: 1, @@ -259,19 +269,31 @@ describe('OpenRouterAgentProvider', () => { 'never recommend tactical chasing or evacuation from only an event cell', ); expect(patientZeroRequest.messages[0]!.content).toContain( - 'One threat alone does not require a message', + 'An isolated event normally does not justify routine broadcasting, but a strategically meaningful first loss may', + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'Repeated or consecutive subject pressure, or repeated current-alliance pressure, should strongly motivate one new concrete', + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'Compare that recommendation with observation.recentZeroMessages', + ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'choose communicationType "none" for equivalent unchanged advice', ); expect(patientZeroRequest.messages[0]!.content).toContain( 'Never repeat near-identical warnings or consecutive unchanged advice', ); + expect(patientZeroRequest.messages[0]!.content).toContain( + 'when no new action is available', + ); expect(patientZeroRequest.messages[0]!.content).toContain( 'prefer alliance-level coordination naming actual agents or allies', ); expect(patientZeroRequest.messages[0]!.content).toContain( 'one bounded meaningful pressure pattern, not a log of every event', ); - expect(patientZeroRequest.messages[0]!.content).toContain( - 'choose communicationType "none" when no new action is available', + expect(patientZeroRequest.messages[1]!.content).toContain( + 'consecutiveAffectedTicks', ); expect(patientZeroRequest.messages[1]!.content).toContain('Rook'); }); diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index ac98d8d..5805f78 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -134,7 +134,7 @@ export function buildOpenRouterRequest( '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". observation.patientZeroGlobalView.playerThreatFeed contains only this interval\'s authoritative successful disinfections and occupied-cell blocked cleans. A blocked clean is a successful historical defense caused by agent occupancy: never tell the blocker to vacate solely because of it; consider holding, nearby redundancy or reinfection, or named allied reinforcement. A successful disinfection is a confirmed loss at a historical event cell, not the cleaner\'s current position; reclaim versus redirect is a strategic choice, never automatic. Recipients receive committed directives on a later tick, so never recommend tactical chasing or evacuation from only an event cell. Broadcast cleaner guidance only when evidence materially changes a recommendation, such as a newly affected agent or alliance, sustained losses, meaningful frontier impact, or actionable named reinforcement. One threat alone does not require a message. Never repeat near-identical warnings or consecutive unchanged advice; choose communicationType "none" when no new action is available. After repeated pressure on an allied member, prefer alliance-level coordination naming actual agents or allies from the authoritative global view. Compact memory may retain one bounded meaningful pressure pattern, not a log of every event. Counts and truncation disclose omitted entries. Never infer cleaner movement, live position, route, target, identity, or future timing. 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 one normal world action and the normal engine 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 interval\'s authoritative successful disinfections and occupied-cell blocked cleans; each event.pressureContext is the authoritative six-tick subject/current-alliance rollup. A blocked clean is a successful historical defense caused by agent occupancy: never tell the blocker to vacate solely because of it; consider holding, nearby redundancy or reinfection, or named allied reinforcement. A successful disinfection is a confirmed loss at a historical event cell, not the cleaner\'s current position; reclaim versus redirect is a strategic choice, never automatic. Recipients receive committed directives on a later tick, so never recommend tactical chasing or evacuation from only an event cell. An isolated event normally does not justify routine broadcasting, but a strategically meaningful first loss may. Repeated or consecutive subject pressure, or repeated current-alliance pressure, should strongly motivate one new concrete hold, reinforce, redundancy, reclaim, or redirect directive when one is actionable. Compare that recommendation with observation.recentZeroMessages; choose communicationType "none" for equivalent unchanged advice or when no new action is available. Never repeat near-identical warnings or consecutive unchanged advice. After repeated pressure on an allied member, prefer alliance-level coordination naming actual agents or allies from the authoritative global view. Compact memory may retain one bounded meaningful pressure pattern, not a log of every event. Counts and truncation disclose omitted entries. Never infer cleaner movement, live position, route, target, identity, or future timing. 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 one normal world action and the normal engine 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 c282b7f..2d3e172 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -183,6 +183,16 @@ describe('experiment archive', () => { blockingAgentName: raw.agents[0]!.name, blockingAllianceId: null, blockingAllianceColor: null, + pressureContext: { + window: { tickCount: 1, startTick: 1, endTick: 1 }, + subject: { + totalEvents: 1, + disinfections: 0, + blockedCleans: 1, + consecutiveAffectedTicks: 1, + }, + currentAlliance: null, + }, }, ], totalEventCount: 1, @@ -232,6 +242,13 @@ describe('experiment archive', () => { { kind: 'occupied-clean-blocked', blockingAgentName: raw.agents[0]!.name, + pressureContext: { + window: { tickCount: 1, startTick: 1, endTick: 1 }, + subject: { + totalEvents: 1, + blockedCleans: 1, + }, + }, }, ], }, diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 42102f5..da1ab54 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -493,6 +493,16 @@ describe('agent observation and decision schemas', () => { }); it('caps Patient Zero cleaner evidence with truthful overflow metadata', () => { + const pressureContext = { + window: { tickCount: 6, startTick: 3, endTick: 8 }, + subject: { + totalEvents: 3, + disinfections: 2, + blockedCleans: 1, + consecutiveAffectedTicks: 2, + }, + currentAlliance: null, + }; const events = Array.from( { length: PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT }, (_, index) => ({ @@ -504,6 +514,7 @@ describe('agent observation and decision schemas', () => { affectedAgentName: 'Ember', affectedAllianceId: null, affectedAllianceColor: null, + pressureContext, }), ); expect( @@ -526,6 +537,123 @@ describe('agent observation and decision schemas', () => { truncated: false, }).success, ).toBe(false); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...events[0]!, + pressureContext: { + ...pressureContext, + window: { tickCount: 2, startTick: 7, endTick: 8 }, + subject: { + ...pressureContext.subject, + consecutiveAffectedTicks: 3, + }, + }, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(false); + const blockedBase = { + eventId: events[0]!.eventId, + cell: events[0]!.cell, + occurredAt: events[0]!.occurredAt, + }; + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...blockedBase, + kind: 'territory-disinfected', + affectedAgentId: agentId, + affectedAgentName: 'Ember', + affectedAllianceId: null, + affectedAllianceColor: null, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(true); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...events[0]!, + pressureContext: { + ...pressureContext, + subject: { + ...pressureContext.subject, + totalEvents: 4, + }, + }, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(false); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...events[0]!, + pressureContext: { + ...pressureContext, + window: { tickCount: 5, startTick: 3, endTick: 8 }, + }, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(false); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...events[0]!, + pressureContext: { + ...pressureContext, + currentAlliance: { + totalEvents: 3, + disinfections: 2, + blockedCleans: 1, + }, + }, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(false); + expect( + patientZeroPlayerThreatFeedSchema.safeParse({ + events: [ + { + ...blockedBase, + kind: 'occupied-clean-blocked', + blockingAgentId: agentId, + blockingAgentName: 'Ember', + blockingAllianceId: null, + blockingAllianceColor: null, + pressureContext: { + ...pressureContext, + subject: { + totalEvents: 1, + disinfections: 1, + blockedCleans: 0, + consecutiveAffectedTicks: 1, + }, + }, + }, + ], + totalEventCount: 1, + truncated: false, + }).success, + ).toBe(false); expect( patientZeroPlayerThreatFeedSchema.safeParse({ events, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8d7943d..83a8ac6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1148,6 +1148,84 @@ export const observedPlayerThreatSchema = z export type ObservedPlayerThreat = z.infer; export const PATIENT_ZERO_PLAYER_THREAT_FEED_LIMIT = 128; +export const PATIENT_ZERO_PRESSURE_WINDOW_TICKS = 6; +const PATIENT_ZERO_PRESSURE_EVENT_COUNT_LIMIT = 1_000_000; +const patientZeroPressureCountsSchema = z + .object({ + totalEvents: z + .number() + .int() + .nonnegative() + .max(PATIENT_ZERO_PRESSURE_EVENT_COUNT_LIMIT), + disinfections: z + .number() + .int() + .nonnegative() + .max(PATIENT_ZERO_PRESSURE_EVENT_COUNT_LIMIT), + blockedCleans: z + .number() + .int() + .nonnegative() + .max(PATIENT_ZERO_PRESSURE_EVENT_COUNT_LIMIT), + }) + .strict() + .superRefine((counts, context) => { + if (counts.totalEvents !== counts.disinfections + counts.blockedCleans) + context.addIssue({ + code: 'custom', + message: 'Player-pressure totals must equal their event categories.', + }); + }); +export const patientZeroPressureContextSchema = z + .object({ + window: z + .object({ + tickCount: z + .number() + .int() + .min(1) + .max(PATIENT_ZERO_PRESSURE_WINDOW_TICKS), + startTick: z.number().int().positive(), + endTick: z.number().int().positive(), + }) + .strict(), + subject: patientZeroPressureCountsSchema.safeExtend({ + consecutiveAffectedTicks: z + .number() + .int() + .min(1) + .max(PATIENT_ZERO_PRESSURE_WINDOW_TICKS), + }), + currentAlliance: patientZeroPressureCountsSchema.nullable(), + }) + .strict() + .superRefine((pressure, context) => { + if ( + pressure.window.endTick - pressure.window.startTick + 1 !== + pressure.window.tickCount || + pressure.subject.totalEvents < 1 || + pressure.subject.consecutiveAffectedTicks > pressure.window.tickCount || + pressure.subject.consecutiveAffectedTicks > pressure.subject.totalEvents + ) + context.addIssue({ + code: 'custom', + message: 'Player-pressure window and subject counts must be truthful.', + }); + if ( + pressure.currentAlliance && + (pressure.currentAlliance.totalEvents < pressure.subject.totalEvents || + pressure.currentAlliance.disinfections < + pressure.subject.disinfections || + pressure.currentAlliance.blockedCleans < pressure.subject.blockedCleans) + ) + context.addIssue({ + code: 'custom', + message: 'Current-alliance pressure cannot be below subject pressure.', + }); + }); +export type PatientZeroPressureContext = z.infer< + typeof patientZeroPressureContextSchema +>; const patientZeroDisinfectionThreatSchema = z .object({ eventId: eventIdSchema, @@ -1158,6 +1236,7 @@ const patientZeroDisinfectionThreatSchema = z affectedAgentName: z.string().trim().min(1).max(80), affectedAllianceId: allianceIdSchema.nullable(), affectedAllianceColor: z.enum(ALLIANCE_COLOR_PALETTE).nullable(), + pressureContext: patientZeroPressureContextSchema.optional(), }) .strict(); const patientZeroBlockedCleanThreatSchema = z @@ -1170,12 +1249,16 @@ const patientZeroBlockedCleanThreatSchema = z blockingAgentName: z.string().trim().min(1).max(80), blockingAllianceId: allianceIdSchema.nullable(), blockingAllianceColor: z.enum(ALLIANCE_COLOR_PALETTE).nullable(), + pressureContext: patientZeroPressureContextSchema.optional(), }) .strict(); export const patientZeroPlayerThreatEventSchema = z.discriminatedUnion('kind', [ patientZeroDisinfectionThreatSchema, patientZeroBlockedCleanThreatSchema, ]); +export type PatientZeroPlayerThreatEvent = z.infer< + typeof patientZeroPlayerThreatEventSchema +>; export const patientZeroPlayerThreatFeedSchema = z .object({ events: z @@ -1211,6 +1294,19 @@ export const patientZeroPlayerThreatFeedSchema = z message: 'Patient Zero player-threat alliance attribution must be complete.', }); + if ( + event.pressureContext && + ((allianceId === null) !== + (event.pressureContext.currentAlliance === null) || + (event.kind === 'territory-disinfected' + ? event.pressureContext.subject.disinfections < 1 + : event.pressureContext.subject.blockedCleans < 1)) + ) + context.addIssue({ + code: 'custom', + message: + 'Patient Zero event pressure must include the current event and current alliance state.', + }); } }); export type PatientZeroPlayerThreatFeed = z.infer<