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
11 changes: 11 additions & 0 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
88 changes: 88 additions & 0 deletions apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
SimulationTurnCancelledError,
SimulationValidationError,
selectDiplomacyBlockerExamples,
selectMostRecentPatientZeroThreats,
applyGoalRevision,
applyMemoryOperation,
} from './simulation-service';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down
78 changes: 78 additions & 0 deletions apps/game-api/src/simulation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
107 changes: 107 additions & 0 deletions apps/world-lab/src/components/behavior-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function acceptedTurn(
inboundMessage?: boolean;
territoryChange?: boolean;
continuity?: boolean;
playerThreats?: boolean;
} = {},
): AgentTurnRecord {
const move = options.move ?? false;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
Loading
Loading