Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
agentTurnRecordSchema,
experimentExportDocumentSchema,
h3CellSchema,
worldEventSchema,
memoryIdSchema,
type Alliance,
type AgentId,
Expand All @@ -42,6 +43,7 @@ import {
SimulationValidationError,
selectDiplomacyBlockerExamples,
selectMostRecentPatientZeroThreats,
calculatePatientZeroPressureContext,
applyGoalRevision,
applyMemoryOperation,
} from './simulation-service';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -225,6 +297,14 @@ describe('SimulationService', () => {
}),
]),
);
expect(
patientZeroObservations
.flatMap(
({ patientZeroGlobalView }) =>
patientZeroGlobalView!.playerThreatFeed!.events,
)
.every(({ pressureContext }) => pressureContext !== undefined),
).toBe(true);
expect(
JSON.stringify(
patientZeroObservations.map(
Expand Down Expand Up @@ -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', () => {
Expand Down
95 changes: 92 additions & 3 deletions apps/game-api/src/simulation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -67,6 +68,7 @@ import {
type AllianceEvent,
type AllianceProposalId,
type SimulatedPlayerEvent,
type PatientZeroPressureContext,
worldSetupRequestSchema,
type AppliedScenario,
type WorldSetupPreviewResponse,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -2143,6 +2230,7 @@ export class SimulationService {
affectedAgentName: referencedAgent.name,
affectedAllianceId: alliance?.id ?? null,
affectedAllianceColor: alliance?.color ?? null,
pressureContext,
}
: {
eventId: event.id,
Expand All @@ -2153,6 +2241,7 @@ export class SimulationService {
blockingAgentName: referencedAgent.name,
blockingAllianceId: alliance?.id ?? null,
blockingAllianceColor: alliance?.color ?? null,
pressureContext,
};
})
: [];
Expand Down
24 changes: 24 additions & 0 deletions apps/world-lab/src/components/behavior-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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');
});
});
20 changes: 16 additions & 4 deletions apps/world-lab/src/components/behavior-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ function collectEvidence(
);
const globalFeed = observation.patientZeroGlobalView?.playerThreatFeed;
const priorGlobalFeed = prior?.patientZeroGlobalView?.playerThreatFeed;
const pressureSuffix = (
event: NonNullable<typeof globalFeed>['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,
Expand All @@ -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,
}))
: [];
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions apps/world-lab/src/components/world-lab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
);
Expand Down
Loading
Loading