From aa2c8b3a3a02cf5f8ebaae46b6ca81dd49eef5a7 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 20 Aug 2026 02:13:52 -0400 Subject: [PATCH 1/2] feat: add patient zero coordinator experiment --- README.md | 6 + ROADMAP.md | 8 + apps/game-api/src/experiment-export.ts | 175 +++++++++++++++++- apps/game-api/src/simulation-service.test.ts | 75 ++++++++ apps/game-api/src/simulation-service.ts | 108 ++++++++++- apps/world-lab/src/app/styles.css | 16 ++ .../src/components/world-lab.test.tsx | 33 +++- apps/world-lab/src/components/world-lab.tsx | 86 ++++++++- apps/world-lab/src/components/world-map.tsx | 13 +- docs/ARCHITECTURE.md | 9 + docs/GAMEPLAY_FOUNDATION.md | 9 + docs/SECURITY.md | 9 +- docs/TESTING.md | 9 +- .../0004-server-owned-experiment-telemetry.md | 7 + ...nt-awareness-and-private-communications.md | 4 + docs/adr/0013-patient-zero-coordinator.md | 28 +++ packages/agent-runtime/src/index.ts | 15 +- packages/shared/src/index.ts | 145 ++++++++++++++- packages/shared/src/scenario.test.ts | 18 ++ packages/world-engine/src/index.test.ts | 61 ++++++ packages/world-engine/src/index.ts | 35 +++- 21 files changed, 839 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0013-patient-zero-coordinator.md diff --git a/README.md b/README.md index 2012569..2461d4e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Agentborne is the working title for an agent-first geographic experiment. A configurable roster of model-backed agents moves, infects, captures contested territory, communicates, and conducts formal alliance diplomacy while the World Lab exposes every safe decision record. +Scenarios may optionally designate one experimental Patient Zero coordinator. +The role receives bounded global strategic information and can send private +advisory directives, but remains subject to the same movement and world-action +rules as every other agent. The universal flat provider contract is +`text-flat-json-v3`; the objective remains `durable-influence-v2`. + ## Workspace | Path | Responsibility | diff --git a/ROADMAP.md b/ROADMAP.md index e5cfde5..0006045 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # Roadmap +## Experimental Patient Zero coordinator + +The current focused slice designates one normal physical agent, provides a +bounded global strategic summary, and adds private advisory Zero broadcasts +plus direct replies. It intentionally excludes extra movement/actions, +numerical bonuses, forced compliance, live player GPS, capture succession, +simulated players, and simultaneous tick scheduling. + Player development begins only after the agent milestones below demonstrate compelling behavior. Each milestone is intended to remain a focused pull request; do not implement ahead of the current milestone. The [Gameplay Foundation](docs/GAMEPLAY_FOUNDATION.md) records accepted future diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index 309e196..ab396a8 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -9,6 +9,7 @@ import { type AgentId, type AgentObservation, type AgentTurnRecord, + type EventId, type AllianceEvent, type ExperimentExportDocument, type ExperimentExportPreview, @@ -17,6 +18,8 @@ import { type ExperimentId, type ExperimentMetrics, type ExperimentModelConfiguration, + type ModelId, + type ReasoningProfile, type DiplomacyRejectionReason, type DiplomacyResult, type ExportedCommunication, @@ -86,6 +89,21 @@ export class ExperimentMetricAccumulator { ); if (recipient) recipient.directMessagesReceived += 1; } + if ( + turn.outcome !== 'provider-error' && + turn.outcome !== 'operator-skipped' && + turn.communicationResult.requested && + turn.communicationResult.accepted && + turn.communicationResult.event.channel === 'zero' + ) { + for (const recipientId of turn.communicationResult.event.recipientIds) { + const recipient = this.#records.get(recipientId); + if (recipient) { + recipient.zeroRecipientDeliveries += 1; + recipient.zeroDirectiveRecipients.add(recipientId); + } + } + } if ( turn.outcome === 'accepted' && turn.worldActionResult.event.type === 'hex-captured' @@ -191,6 +209,25 @@ interface MutableMetrics { publicMessagesSent: number; directMessagesSent: number; directMessagesReceived: number; + zeroBroadcastsRequested: number; + zeroBroadcastsDelivered: number; + zeroBroadcastsRejected: number; + zeroRecipientDeliveries: number; + zeroDirectiveRecipients: Set; + patientZeroRepliers: Set; + directRepliesToPatientZero: number; + firstZeroDirectiveTurn: number | null; + mostRecentZeroDirective: { + eventId: EventId; + turnNumber: number; + occurredAt: string; + agentId: AgentId; + recipientCount: number; + modelId: ModelId; + reasoningProfile: ReasoningProfile; + personalityId: AgentObservation['behavior']['personalityId']; + strategyId: AgentObservation['behavior']['strategyId']; + } | null; diplomacyProposalsRequested: number; diplomacyAcceptancesRequested: number; diplomacyDeparturesRequested: number; @@ -275,6 +312,15 @@ function mutableMetrics(): MutableMetrics { publicMessagesSent: 0, directMessagesSent: 0, directMessagesReceived: 0, + zeroBroadcastsRequested: 0, + zeroBroadcastsDelivered: 0, + zeroBroadcastsRejected: 0, + zeroRecipientDeliveries: 0, + zeroDirectiveRecipients: new Set(), + patientZeroRepliers: new Set(), + directRepliesToPatientZero: 0, + firstZeroDirectiveTurn: null, + mostRecentZeroDirective: null, diplomacyProposalsRequested: 0, diplomacyAcceptancesRequested: 0, diplomacyDeparturesRequested: 0, @@ -343,7 +389,8 @@ function addToMutable( : turn.communicationResult.attempt.channel; if (channel === 'public') metrics.publicMessagesRequested += 1; else if (channel === 'direct') metrics.directMessagesRequested += 1; - else metrics.allianceMessagesRequested += 1; + else if (channel === 'alliance') metrics.allianceMessagesRequested += 1; + else metrics.zeroBroadcastsRequested += 1; if (turn.communicationResult.accepted) { if (turn.communicationResult.event.channel === 'public') { metrics.publicMessagesAccepted += 1; @@ -352,12 +399,47 @@ function addToMutable( metrics.directMessagesDelivered += 1; metrics.directMessagesSent += 1; if (aggregate) metrics.directMessagesReceived += 1; - } else metrics.allianceMessagesDelivered += 1; + } else if (turn.communicationResult.event.channel === 'alliance') + metrics.allianceMessagesDelivered += 1; + else { + metrics.zeroBroadcastsDelivered += 1; + metrics.zeroRecipientDeliveries += + turn.communicationResult.event.recipientIds.length; + for (const id of turn.communicationResult.event.recipientIds) + metrics.zeroDirectiveRecipients.add(id); + metrics.firstZeroDirectiveTurn ??= turn.turnNumber; + metrics.mostRecentZeroDirective = { + eventId: turn.communicationResult.event.id, + turnNumber: turn.turnNumber, + occurredAt: turn.communicationResult.event.occurredAt, + agentId: turn.agentId, + recipientCount: turn.communicationResult.event.recipientIds.length, + modelId: turn.provider.model, + reasoningProfile: + turn.modelAttempts.at(-1)?.reasoningProfile ?? 'provider-default', + personalityId: turn.observation.behavior.personalityId, + strategyId: turn.observation.behavior.strategyId, + }; + } } else if (turn.communicationResult.attempt.channel === 'public') { metrics.publicMessagesRejected += 1; } else if (turn.communicationResult.attempt.channel === 'direct') metrics.directMessagesRejected += 1; - else metrics.allianceMessagesRejected += 1; + else if (turn.communicationResult.attempt.channel === 'alliance') + metrics.allianceMessagesRejected += 1; + else metrics.zeroBroadcastsRejected += 1; + } + if ( + turn.communicationResult.requested && + turn.communicationResult.accepted && + turn.communicationResult.event.channel === 'direct' && + turn.observation.patientZero.agentId !== null && + turn.agentId !== turn.observation.patientZero.agentId && + turn.communicationResult.event.recipientId === + turn.observation.patientZero.agentId + ) { + metrics.directRepliesToPatientZero += 1; + metrics.patientZeroRepliers.add(turn.agentId); } if (turn.diplomacyResult.requested) { const type = turn.diplomacyResult.accepted @@ -556,6 +638,15 @@ function finalizeMutable(metrics: MutableMetrics) { publicMessagesSent: metrics.publicMessagesSent, directMessagesSent: metrics.directMessagesSent, directMessagesReceived: metrics.directMessagesReceived, + zeroBroadcastsRequested: metrics.zeroBroadcastsRequested, + zeroBroadcastsDelivered: metrics.zeroBroadcastsDelivered, + zeroBroadcastsRejected: metrics.zeroBroadcastsRejected, + zeroRecipientDeliveries: metrics.zeroRecipientDeliveries, + uniqueZeroDirectiveRecipients: metrics.zeroDirectiveRecipients.size, + directRepliesToPatientZero: metrics.directRepliesToPatientZero, + uniquePatientZeroRepliers: metrics.patientZeroRepliers.size, + firstZeroDirectiveTurn: metrics.firstZeroDirectiveTurn, + mostRecentZeroDirective: metrics.mostRecentZeroDirective, diplomacyProposalsRequested: metrics.diplomacyProposalsRequested, diplomacyAcceptancesRequested: metrics.diplomacyAcceptancesRequested, diplomacyDeparturesRequested: metrics.diplomacyDeparturesRequested, @@ -1211,7 +1302,7 @@ function summarizeEvent( } function summarizeCommunication( - channel: 'public' | 'direct' | 'alliance', + channel: 'public' | 'direct' | 'alliance' | 'zero', recipientId?: AgentId, distance?: number, ): string { @@ -1219,7 +1310,9 @@ function summarizeCommunication( ? 'Published to world chat.' : channel === 'alliance' ? 'Delivered to current alliance members.' - : `Delivered directly to ${recipientId} from distance ${distance}.`; + : channel === 'zero' + ? 'Delivered privately to all other active agents.' + : `Delivered directly to ${recipientId} from distance ${distance}.`; } export function calculateExperimentMetrics( @@ -1276,6 +1369,37 @@ export function calculateExperimentMetrics( usageAttempts(turn).some(({ kind }) => kind !== 'initial'), ); const movement = movementMetrics(records); + const mostRecentZeroTurn = records.findLast( + (turn) => + turn.outcome !== 'provider-error' && + turn.outcome !== 'operator-skipped' && + turn.communicationResult.requested && + turn.communicationResult.accepted && + turn.communicationResult.event.channel === 'zero', + ); + const mostRecentZeroDirective = + mostRecentZeroTurn && + mostRecentZeroTurn.outcome !== 'provider-error' && + mostRecentZeroTurn.outcome !== 'operator-skipped' && + mostRecentZeroTurn.communicationResult.requested && + mostRecentZeroTurn.communicationResult.accepted && + mostRecentZeroTurn.communicationResult.event.channel === 'zero' + ? { + eventId: mostRecentZeroTurn.communicationResult.event.id, + turnNumber: mostRecentZeroTurn.turnNumber, + occurredAt: mostRecentZeroTurn.communicationResult.event.occurredAt, + agentId: mostRecentZeroTurn.agentId, + recipientCount: + mostRecentZeroTurn.communicationResult.event.recipientIds.length, + modelId: mostRecentZeroTurn.provider.model, + reasoningProfile: + mostRecentZeroTurn.modelAttempts.at(-1)?.reasoningProfile ?? + 'provider-default', + personalityId: + mostRecentZeroTurn.observation.behavior.personalityId, + strategyId: mostRecentZeroTurn.observation.behavior.strategyId, + } + : null; return { totalTurns: records.length, accepted: records.filter(({ outcome }) => outcome === 'accepted').length, @@ -1376,6 +1500,7 @@ export function calculateExperimentMetrics( scopedAgentIds.includes(previousControllerAgentId), ).length, ...communicationMetrics(relevantCommunications, scopedAgentIds, agentId), + mostRecentZeroDirective, ...diplomacyMetrics( records, agentId @@ -1739,6 +1864,21 @@ function communicationMetrics( communication.channel === 'alliance' && authoredBySelection(communication), ); + const zeroAuthored = communications.filter( + (communication) => + communication.channel === 'zero' && authoredBySelection(communication), + ); + const patientZeroId = communications.find( + ({ channel, status }) => channel === 'zero' && status === 'accepted', + )?.agentId; + const repliesToPatientZero = patientZeroId + ? directAuthored.filter( + ({ status, recipientId, agentId: senderId }) => + status === 'accepted' && + recipientId === patientZeroId && + senderId !== patientZeroId, + ) + : []; const deliveredDirect = directAuthored.filter( (communication) => communication.status === 'accepted' && @@ -1771,6 +1911,31 @@ function communicationMetrics( allianceMessagesRejected: allianceAuthored.filter( ({ status }) => status === 'rejected', ).length, + zeroBroadcastsRequested: zeroAuthored.length, + zeroBroadcastsDelivered: zeroAuthored.filter( + ({ status }) => status === 'accepted', + ).length, + zeroBroadcastsRejected: zeroAuthored.filter( + ({ status }) => status === 'rejected', + ).length, + zeroRecipientDeliveries: zeroAuthored.reduce( + (sum, communication) => + sum + + (communication.status === 'accepted' + ? (communication.recipientIds?.length ?? 0) + : 0), + 0, + ), + uniqueZeroDirectiveRecipients: new Set( + zeroAuthored.flatMap(({ recipientIds }) => recipientIds ?? []), + ).size, + directRepliesToPatientZero: repliesToPatientZero.length, + uniquePatientZeroRepliers: new Set( + repliesToPatientZero.map(({ agentId: senderId }) => senderId), + ).size, + firstZeroDirectiveTurn: + zeroAuthored.find(({ status }) => status === 'accepted') + ?.originatingTurn ?? null, publicMessagesSent: publicAuthored.filter( ({ status }) => status === 'accepted', ).length, diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 22ef490..6db489d 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -97,6 +97,81 @@ describe('SimulationService', () => { expect(applied.scenario.communicationRangeKm).toBe(7.5); }); + it('gives only Patient Zero bounded global awareness and delivers a Zero directive plus global reply', async () => { + const seen: AgentObservation[] = []; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(observation): Promise { + seen.push(structuredClone(observation)); + return { + decision: { + worldAction: { type: 'wait' }, + communication: observation.patientZero.isPatientZero + ? { channel: 'zero', message: 'Take separate infection fronts.' } + : { + channel: 'direct', + recipientId: observation.patientZero.agentId!, + message: 'Taking the eastern front.', + }, + summary: 'Coordinate while waiting.', + }, + metadata: { + provider: 'scripted-test', + model: 'deterministic-script', + latencyMs: 0, + }, + }; + }, + }); + const setup = defaultWorldSetupRequest(); + const patientZeroId = setup.roster[0]!.id; + simulation.applyWorldSetup({ + ...setup, + patientZeroAgentId: patientZeroId, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + }); + const directive = await simulation.executeNextTurn(); + const reply = await simulation.executeNextTurn(); + expect(directive.observation.patientZeroGlobalView?.agents).toHaveLength(8); + expect(reply.observation.patientZeroGlobalView).toBeNull(); + expect( + JSON.stringify(directive.observation.patientZeroGlobalView), + ).not.toMatch(/provider|credential|pendingDecision|gps|future/i); + if ( + directive.outcome === 'provider-error' || + directive.outcome === 'operator-skipped' || + reply.outcome === 'provider-error' || + reply.outcome === 'operator-skipped' + ) + throw new Error('Expected completed Patient Zero communication turns.'); + expect(directive.communicationResult).toMatchObject({ + accepted: true, + event: { channel: 'zero', recipientIds: expect.any(Array) }, + }); + expect(reply.communicationResult).toMatchObject({ + accepted: true, + event: { channel: 'direct', recipientId: patientZeroId }, + }); + expect(reply.observation.recentZeroMessages).toHaveLength(1); + expect(seen).toHaveLength(2); + expect(simulation.getSnapshot().experiment.metrics.aggregate).toMatchObject( + { + zeroBroadcastsRequested: 1, + zeroBroadcastsDelivered: 1, + zeroRecipientDeliveries: 7, + uniqueZeroDirectiveRecipients: 7, + directRepliesToPatientZero: 1, + uniquePatientZeroRepliers: 1, + firstZeroDirectiveTurn: 1, + }, + ); + }); + it('reproduces move ordering for identical inputs and varies it across agents', async () => { const provider = () => new ScriptedAgentProvider( diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 4c1b57d..588b95d 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -6,6 +6,7 @@ import { } from '@agentborne/agent-runtime'; import { agentIdSchema, + appliedScenarioSchema, assignBehavior, behaviorConfigurationSchema, agentObservationSchema, @@ -24,6 +25,8 @@ import { RECENT_PUBLIC_MESSAGE_LIMIT, RECENT_CONTROL_CHANGE_LIMIT, RECENT_ALLIANCE_EVENT_LIMIT, + RECENT_ZERO_MESSAGE_LIMIT, + RECENT_ZERO_STRATEGIC_EVENT_LIMIT, PERSONALITY_MAX_LENGTH, OPENROUTER_PROVIDER_TIMEOUT_MS, OPENROUTER_429_FALLBACK_BACKOFF_MS, @@ -595,6 +598,7 @@ export class SimulationService { legacyConfiguration, ); this.#modelConfiguration = legacyConfiguration; + this.#scenario = { ...this.#scenario, patientZeroAgentId: null }; return { snapshot: this.getSnapshot(), legacy: true, @@ -614,7 +618,19 @@ export class SimulationService { 'invalid_model_configuration', 'The imported model assignment is invalid.', ); + const importedPatientZero = + version === 9 && + typeof experiment?.scenario === 'object' && + experiment.scenario !== null + ? (appliedScenarioSchema.safeParse(experiment.scenario).data + ?.patientZeroAgentId ?? null) + : null; const knownAgents = new Set(this.#state.agents.keys()); + if (importedPatientZero && !knownAgents.has(importedPatientZero)) + throw new SimulationValidationError( + 'unknown_agent', + 'The imported Patient Zero designation references an unknown agent.', + ); if ( configuration.data.overrides.some( ({ agentId }) => !knownAgents.has(agentId), @@ -658,6 +674,10 @@ export class SimulationService { importedConfiguration, ); this.#modelConfiguration = importedConfiguration; + this.#scenario = { + ...this.#scenario, + patientZeroAgentId: importedPatientZero, + }; return { snapshot: this.getSnapshot(), legacy: false, @@ -1072,6 +1092,7 @@ export class SimulationService { createAllianceId: this.#createAllianceId, createProposalId: this.#createProposalId, communicationRangeKm: this.#scenario.communicationRangeKm, + patientZeroAgentId: this.#scenario.patientZeroAgentId, }; const appliedAction = applyWorldAction( preActionState, @@ -1422,6 +1443,7 @@ export class SimulationService { })); const captureEligibility = getCaptureEligibility(this.#state, agent.id); const actingAlliance = getAgentAlliance(this.#state, agent.id); + const patientZeroAgentId = this.#scenario.patientZeroAgentId; const territory = this.#territoryScoreboard(); const nearbyAgents = [...this.#state.agents.values()] .filter((candidate) => candidate.id !== agent.id) @@ -1444,16 +1466,26 @@ export class SimulationService { ?.controlledCellCount ?? 0, })) .filter( - ({ distanceKm, allianceRelationship }) => + ({ id, distanceKm, allianceRelationship }) => allianceRelationship === 'allied' || - distanceKm <= this.#scenario.communicationRangeKm, + distanceKm <= this.#scenario.communicationRangeKm || + id === patientZeroAgentId || + agent.id === patientZeroAgentId, ) .map((entry) => ({ ...entry, directMessageLegal: - entry.distanceKm <= this.#scenario.communicationRangeKm, + entry.distanceKm <= this.#scenario.communicationRangeKm || + entry.id === patientZeroAgentId || + agent.id === patientZeroAgentId, })) - .sort((a, b) => a.distanceKm - b.distanceKm || a.id.localeCompare(b.id)) + .sort( + (a, b) => + Number(b.id === patientZeroAgentId) - + Number(a.id === patientZeroAgentId) || + a.distanceKm - b.distanceKm || + a.id.localeCompare(b.id), + ) .slice(0, 8); const recentEvents = this.#state.events .filter( @@ -1545,6 +1577,25 @@ export class SimulationService { occurredAt: event.occurredAt, }; }); + const recentZeroMessages = this.#state.events + .filter( + (event): event is Extract => + event.type === 'zero-message-sent' && + (event.agentId === agent.id || event.recipientIds.includes(agent.id)), + ) + .slice(-RECENT_ZERO_MESSAGE_LIMIT) + .map((event) => { + const sender = this.#state.agents.get(event.agentId); + if (!sender) throw new Error('A Zero-message sender does not exist.'); + return { + eventId: event.id, + senderId: sender.id, + senderName: sender.name, + recipientCount: event.recipientIds.length, + message: event.message, + occurredAt: event.occurredAt, + }; + }); const recentControlChanges = this.#state.events .filter( (event): event is Extract => @@ -1631,6 +1682,7 @@ export class SimulationService { alliance: actingAlliance ? { available: true, allianceId: actingAlliance.id } : { available: false, allianceId: null }, + zero: { available: agent.id === patientZeroAgentId }, }, adjacentCells, nearbyAgents, @@ -1638,6 +1690,54 @@ export class SimulationService { recentPublicMessages, recentDirectMessages, recentAllianceMessages, + recentZeroMessages, + patientZero: { + agentId: patientZeroAgentId, + agentName: + (patientZeroAgentId + ? this.#state.agents.get(patientZeroAgentId)?.name + : null) ?? null, + isPatientZero: agent.id === patientZeroAgentId, + directRangeBypass: patientZeroAgentId !== null, + }, + patientZeroGlobalView: + agent.id === patientZeroAgentId + ? { + agents: [...this.#state.agents.values()].map((candidate) => ({ + id: candidate.id, + name: candidate.name, + currentCell: candidate.currentCell, + allianceId: + getAgentAlliance(this.#state, candidate.id)?.id ?? null, + controlledCellCount: + territory.find(({ agentId: id }) => id === candidate.id) + ?.controlledCellCount ?? 0, + personality: candidate.personality, + strategyId: this.#behaviorFor(candidate.id).strategyId, + })), + individualTerritory: territory, + allianceTerritory: this.#allianceTerritorySummaries(), + alliances: [...(this.#state.alliances?.values() ?? [])], + activeAllianceProposals: [ + ...(this.#state.pendingAllianceProposals?.values() ?? []), + ], + recentStrategicEvents: this.#state.events + .filter(isAllianceEvent) + .slice(-RECENT_ZERO_STRATEGIC_EVENT_LIMIT) + .map((event) => ({ + event, + summary: summarizeAllianceEvent(event, this.#state), + })), + recentTerritoryChanges: this.#state.events + .filter( + ( + event, + ): event is Extract => + event.type === 'hex-captured', + ) + .slice(-RECENT_CONTROL_CHANGE_LIMIT), + } + : null, territoryScoreboard: this.#territoryScoreboard(), actingAllianceId: getAgentAlliance(this.#state, agent.id)?.id ?? null, actingAlliance: diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index bca30b6..02c6baa 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -440,6 +440,22 @@ h2 { 0 2px 8px #000b; transform: scale(1.12); } +.agent-marker.patient-zero { + outline: 3px double #fff5d8; + outline-offset: 2px; +} +.patient-zero-badge { + display: inline-flex; + margin-left: 0.4rem; + padding: 0.08rem 0.35rem; + border: 1px solid #fff5d8; + border-radius: 999px; + color: #fff5d8; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.04em; + vertical-align: middle; +} .map-caption { position: absolute; inset: auto 0 0; diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index edc85d2..2b247c1 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1034,6 +1034,37 @@ describe('WorldLab', () => { expect(overflowTrigger.closest('details')).not.toHaveAttribute('open'); }); + it('shows Patient Zero in the roster, marker, inspector, setup selector, and private filter', async () => { + const patientZero = initial.world.agents[0]!; + const designated = simulationSnapshotSchema.parse({ + ...initial, + scenario: { ...initial.scenario, patientZeroAgentId: patientZero.id }, + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(designated)), + ); + const user = userEvent.setup(); + render(); + expect(await screen.findByText('HEX-0')).toBeInTheDocument(); + expect( + await screen.findByRole('button', { + name: `Select agent ${patientZero.name}, Patient Zero`, + }), + ).toHaveClass('patient-zero'); + await user.click( + within( + screen.getByRole('complementary', { name: 'Agent roster' }), + ).getByRole('button', { name: new RegExp(patientZero.name) }), + ); + expect(screen.getByText('Patient Zero role')).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /Private comms/ })); + expect(screen.getByRole('button', { name: 'Zero' })).toBeInTheDocument(); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'World setup' })); + expect(screen.getByLabelText('Patient Zero')).toHaveValue(patientZero.id); + }); + it('exposes opt-in bounded unattended recovery with provider-cost guidance', async () => { const user = userEvent.setup(); render(); @@ -2120,7 +2151,7 @@ describe('WorldLab', () => { return jsonResponse({ verification: { modelId: 'example/alpha', - contractVersion: 'text-flat-json-v2', + contractVersion: 'text-flat-json-v3', status: probeCalls === 1 ? 'failed' : 'verified', testedAt: '2026-08-15T12:00:00.000Z', ...(probeCalls === 1 diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 0845c0e..6602cfc 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -103,7 +103,8 @@ export function WorldLab() { snapshot?.world.events.filter( (event) => event.type === 'direct-message-sent' || - event.type === 'alliance-message-sent', + event.type === 'alliance-message-sent' || + event.type === 'zero-message-sent', ).length ?? 0; const previousPrivateCommCount = useRef(null); useEffect(() => { @@ -1013,6 +1014,14 @@ export function WorldLab() {
Retained turns
{snapshot.experiment.retainedTurns}
+
+
Patient Zero
+
+ {snapshot.world.agents.find( + ({ id }) => id === snapshot.scenario.patientZeroAgentId, + )?.name ?? 'None'} +
+
Public messages
@@ -1443,6 +1452,7 @@ export function WorldLab() { hexes={snapshot.world.hexes} agents={snapshot.world.agents} alliances={snapshot.world.alliances} + patientZeroAgentId={snapshot.scenario.patientZeroAgentId} selectedCell={selectedCell} selectedAgentId={inspectionAgentId} onSelectCell={(cell) => { @@ -2046,6 +2056,7 @@ function WorldSetupPanel({ spawnSeed: scenario.spawnSeed, minimumSpawnSeparation: scenario.minimumSpawnSeparation, communicationRangeKm: scenario.communicationRangeKm, + patientZeroAgentId: scenario.patientZeroAgentId, roster: scenario.roster, modelConfiguration: scenario.modelConfiguration, behaviorConfiguration: scenario.behaviorConfiguration, @@ -2095,6 +2106,10 @@ function WorldSetupPanel({ return { ...current, roster, + patientZeroAgentId: + current.patientZeroAgentId && ids.has(current.patientZeroAgentId) + ? current.patientZeroAgentId + : null, modelConfiguration: { ...current.modelConfiguration, overrides: current.modelConfiguration.overrides.filter( @@ -2521,6 +2536,27 @@ function WorldSetupPanel({

Assignments

+