diff --git a/apps/game-api/src/observation-history.test.ts b/apps/game-api/src/observation-history.test.ts new file mode 100644 index 0000000..c12e323 --- /dev/null +++ b/apps/game-api/src/observation-history.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + agentIdSchema, + worldEventSchema, + type AgentId, + type WorldEvent, +} from '@hexzero/shared'; +import { ObservationHistory } from './observation-history'; + +const actor = agentIdSchema.parse('00000000-0000-4000-8000-000000000001'); +const peer = agentIdSchema.parse('00000000-0000-4000-8000-000000000002'); +const outsider = agentIdSchema.parse('00000000-0000-4000-8000-000000000003'); +const cellA = '892a94d232bffff'; +const cellB = '892a94d2323ffff'; + +describe('ObservationHistory', () => { + it('retains independent factual streams through more than 120 unrelated events', () => { + const history = new ObservationHistory(); + history.ingest([ + ...range(7).map((index) => movement(actor, index)), + ...range(10).map((index) => waited(actor, index)), + ...range(15).map((index) => publicMessage(actor, index)), + ...range(7).map((index) => directMessage(actor, peer, index)), + ...range(7).map((index) => allianceMessage(actor, [peer], index)), + ...range(7).map((index) => zeroMessage(actor, [peer], index)), + ...range(7).map((index) => capture(actor, peer, index)), + ...range(14).map((index) => proposed(actor, peer, index)), + ]); + history.ingest( + range(130).map((index) => directMessage(outsider, peer, index + 100)), + ); + + expect(ids(history.movements(actor))).toEqual( + range(6).map((index) => eventId(index + 1)), + ); + expect(history.actions()).toHaveLength(8); + expect(history.publicMessages()).toHaveLength(12); + expect(history.directMessages(actor)).toHaveLength(6); + expect(history.directMessages(outsider)).toHaveLength(6); + expect(history.directMessages(agent('4'))).toEqual([]); + expect(history.allianceMessages(actor)).toHaveLength(6); + expect(history.allianceMessages(peer)).toHaveLength(6); + expect(history.allianceMessages(outsider)).toEqual([]); + expect(history.zeroMessages(actor)).toHaveLength(6); + expect(history.zeroMessages(peer)).toHaveLength(6); + expect(history.zeroMessages(outsider)).toEqual([]); + expect(history.controlChanges(actor)).toHaveLength(6); + expect(history.controlChanges(peer)).toHaveLength(6); + expect(history.captures()).toHaveLength(6); + expect(history.allianceEvents(8)).toHaveLength(8); + expect(history.allianceEvents(12)).toHaveLength(12); + }); + + it('initializes existing facts in order and ingests an event ID only once', () => { + const first = directMessage(actor, peer, 1); + const second = directMessage(peer, actor, 2); + const history = new ObservationHistory([first]); + history.ingest([first, second]); + + expect(ids(history.directMessages(actor))).toEqual([first.id, second.id]); + expect(ids(history.directMessages(peer))).toEqual([first.id, second.id]); + }); +}); + +function range(length: number): number[] { + return Array.from({ length }, (_, index) => index); +} + +function agent(last: string): AgentId { + return agentIdSchema.parse(`00000000-0000-4000-8000-00000000000${last}`); +} + +function eventId(index: number): string { + return `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +} + +function event(input: Record, index: number): WorldEvent { + return worldEventSchema.parse({ + id: eventId(index), + occurredAt: `2026-08-25T12:${String(Math.floor(index / 60)).padStart(2, '0')}:${String(index % 60).padStart(2, '0')}.000Z`, + ...input, + }); +} + +function movement(agentId: AgentId, index: number): WorldEvent { + return event( + { type: 'agent-moved', agentId, fromCell: cellA, toCell: cellB }, + index, + ); +} + +function waited(agentId: AgentId, index: number): WorldEvent { + return event({ type: 'agent-waited', agentId }, index + 20); +} + +function publicMessage(agentId: AgentId, index: number): WorldEvent { + return event( + { + type: 'public-message-sent', + channel: 'public', + agentId, + message: `public ${index}`, + playerVisible: true, + }, + index + 40, + ); +} + +function directMessage( + agentId: AgentId, + recipientId: AgentId, + index: number, +): WorldEvent { + return event( + { + type: 'direct-message-sent', + channel: 'direct', + agentId, + recipientId, + message: `direct ${index}`, + distance: 1, + playerVisible: false, + }, + index + 1000, + ); +} + +function allianceMessage( + agentId: AgentId, + recipientIds: AgentId[], + index: number, +): WorldEvent { + return event( + { + type: 'alliance-message-sent', + channel: 'alliance', + agentId, + recipientIds, + allianceId: '20000000-0000-4000-8000-000000000001', + message: `alliance ${index}`, + playerVisible: false, + }, + index + 70, + ); +} + +function zeroMessage( + agentId: AgentId, + recipientIds: AgentId[], + index: number, +): WorldEvent { + return event( + { + type: 'zero-message-sent', + channel: 'zero', + agentId, + recipientIds, + message: `zero ${index}`, + playerVisible: false, + }, + index + 80, + ); +} + +function capture( + controllerAgentId: AgentId, + previousControllerAgentId: AgentId, + index: number, +): WorldEvent { + return event( + { + type: 'hex-captured', + agentId: controllerAgentId, + controllerAgentId, + previousControllerAgentId, + cell: cellA, + }, + index + 90, + ); +} + +function proposed( + agentId: AgentId, + recipientAgentId: AgentId, + index: number, +): WorldEvent { + return event( + { + type: 'alliance-proposed', + agentId, + recipientAgentId, + proposalId: `30000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + allianceId: null, + turnNumber: index + 1, + expirationTurn: index + 10, + }, + index + 110, + ); +} + +function ids(events: readonly WorldEvent[]): string[] { + return events.map(({ id }) => id); +} diff --git a/apps/game-api/src/observation-history.ts b/apps/game-api/src/observation-history.ts new file mode 100644 index 0000000..cbb6155 --- /dev/null +++ b/apps/game-api/src/observation-history.ts @@ -0,0 +1,197 @@ +import { + RECENT_ALLIANCE_EVENT_LIMIT, + RECENT_CONTROL_CHANGE_LIMIT, + RECENT_DIRECT_MESSAGE_LIMIT, + RECENT_PUBLIC_MESSAGE_LIMIT, + RECENT_ZERO_MESSAGE_LIMIT, + RECENT_ZERO_STRATEGIC_EVENT_LIMIT, + type AgentId, + type AllianceEvent, + type WorldEvent, +} from '@hexzero/shared'; + +type EventOf = Extract; +type ActionEvent = EventOf< + 'agent-moved' | 'hex-infected' | 'hex-captured' | 'agent-waited' +>; + +const RECENT_MOVEMENT_LIMIT = 6; +const RECENT_ACTION_LIMIT = 8; + +/** + * Commit-owned factual history for agent observations. This is deliberately + * independent from WorldState.events, whose 120-event retention serves the + * operator display rather than the observation contract. + */ +export class ObservationHistory { + #movements = new Map[]>(); + #actions: ActionEvent[] = []; + #publicMessages: EventOf<'public-message-sent'>[] = []; + #directMessages = new Map[]>(); + #allianceMessages = new Map[]>(); + #zeroMessages = new Map[]>(); + #controlChanges = new Map[]>(); + #allianceEvents: AllianceEvent[] = []; + #captures: EventOf<'hex-captured'>[] = []; + + constructor(initialEvents: readonly WorldEvent[] = []) { + this.ingest(initialEvents); + } + + ingest(events: readonly WorldEvent[]): void { + // Engine event IDs are authoritative and unique. The batch set protects + // initialization/call-site mistakes, while #contains scans only bounded + // retained facts and therefore cannot become a lifetime event registry. + const batchEventIds = new Set(); + for (const event of events) { + if (batchEventIds.has(event.id) || this.#contains(event.id)) continue; + batchEventIds.add(event.id); + switch (event.type) { + case 'agent-moved': + appendFor( + this.#movements, + event.agentId, + event, + RECENT_MOVEMENT_LIMIT, + ); + this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); + break; + case 'hex-infected': + case 'agent-waited': + this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); + break; + case 'hex-captured': + this.#actions = append(this.#actions, event, RECENT_ACTION_LIMIT); + this.#captures = append( + this.#captures, + event, + RECENT_CONTROL_CHANGE_LIMIT, + ); + appendFor( + this.#controlChanges, + event.controllerAgentId, + event, + RECENT_CONTROL_CHANGE_LIMIT, + ); + appendFor( + this.#controlChanges, + event.previousControllerAgentId, + event, + RECENT_CONTROL_CHANGE_LIMIT, + ); + break; + case 'public-message-sent': + this.#publicMessages = append( + this.#publicMessages, + event, + RECENT_PUBLIC_MESSAGE_LIMIT, + ); + break; + case 'direct-message-sent': + for (const participant of [event.agentId, event.recipientId]) + appendFor( + this.#directMessages, + participant, + event, + RECENT_DIRECT_MESSAGE_LIMIT, + ); + break; + case 'alliance-message-sent': + for (const participant of new Set([ + event.agentId, + ...event.recipientIds, + ])) + appendFor( + this.#allianceMessages, + participant, + event, + RECENT_DIRECT_MESSAGE_LIMIT, + ); + break; + case 'zero-message-sent': + for (const participant of new Set([ + event.agentId, + ...event.recipientIds, + ])) + appendFor( + this.#zeroMessages, + participant, + event, + RECENT_ZERO_MESSAGE_LIMIT, + ); + break; + case 'alliance-proposed': + case 'alliance-proposal-closed': + case 'alliance-formed': + case 'agent-joined-alliance': + case 'agent-left-alliance': + case 'alliance-dissolved': + this.#allianceEvents = append( + this.#allianceEvents, + event, + RECENT_ZERO_STRATEGIC_EVENT_LIMIT, + ); + break; + case 'simulated-player-moved': + case 'hex-disinfected': + case 'simulated-player-clean-blocked': + break; + } + } + } + + movements(agentId: AgentId) { + return structuredClone(this.#movements.get(agentId) ?? []); + } + actions() { + return structuredClone(this.#actions); + } + publicMessages() { + return structuredClone(this.#publicMessages); + } + directMessages(agentId: AgentId) { + return structuredClone(this.#directMessages.get(agentId) ?? []); + } + allianceMessages(agentId: AgentId) { + return structuredClone(this.#allianceMessages.get(agentId) ?? []); + } + zeroMessages(agentId: AgentId) { + return structuredClone(this.#zeroMessages.get(agentId) ?? []); + } + controlChanges(agentId: AgentId) { + return structuredClone(this.#controlChanges.get(agentId) ?? []); + } + allianceEvents(limit: number = RECENT_ALLIANCE_EVENT_LIMIT) { + return structuredClone(this.#allianceEvents.slice(-limit)); + } + captures() { + return structuredClone(this.#captures); + } + + #contains(eventId: string): boolean { + return [ + ...this.#movements.values(), + this.#actions, + this.#publicMessages, + ...this.#directMessages.values(), + ...this.#allianceMessages.values(), + ...this.#zeroMessages.values(), + ...this.#controlChanges.values(), + this.#allianceEvents, + this.#captures, + ].some((events) => events.some(({ id }) => id === eventId)); + } +} + +function append(items: readonly T[], item: T, limit: number): T[] { + return [...items, structuredClone(item)].slice(-limit); +} + +function appendFor( + map: Map, + agentId: AgentId, + item: T, + limit: number, +): void { + map.set(agentId, append(map.get(agentId) ?? [], item, limit)); +} diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 7cb8733..359b832 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -54,7 +54,11 @@ import { import { geographicDirectionBetweenCells } from './geographic-direction'; const now = () => '2026-08-13T12:00:01.000Z'; -const createEventId = () => '67aa21b9-fc78-4b04-9f92-9862bf346f96'; +function deterministicEventIdGenerator() { + let sequence = 0; + return () => + `67aa21b9-fc78-4b04-9f92-${String(++sequence).padStart(12, '0')}`; +} const compatibleModels: CompatibleModel[] = [ { id: 'author/global-model', @@ -87,7 +91,11 @@ const compatibleModels: CompatibleModel[] = [ ]; function service(provider: AgentProvider) { - return new SimulationService({ provider, now, createEventId }); + return new SimulationService({ + provider, + now, + createEventId: deterministicEventIdGenerator(), + }); } function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') { @@ -1362,7 +1370,7 @@ describe('SimulationService', () => { const simulation = new SimulationService({ provider, now, - createEventId, + createEventId: deterministicEventIdGenerator(), experimentRetentionLimit: 10, }); await simulation.executeNextTick(); @@ -2406,7 +2414,7 @@ describe('SimulationService', () => { const simulation = new SimulationService({ provider, now, - createEventId, + createEventId: deterministicEventIdGenerator(), experimentRetentionLimit: 125, }); for (let index = 0; index < 125; index += 1) @@ -2432,7 +2440,7 @@ describe('SimulationService', () => { { worldAction: { type: 'wait' }, summary: '3' }, ]), now, - createEventId, + createEventId: deterministicEventIdGenerator(), experimentRetentionLimit: 2, }); await simulation.executeNextTurn(); @@ -2521,7 +2529,7 @@ describe('SimulationService', () => { { worldAction: { type: 'wait' }, summary: 'Wait.' }, ]), now, - createEventId, + createEventId: deterministicEventIdGenerator(), createExperimentId: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(++sequence).padStart(12, '0')}`, }); @@ -3458,7 +3466,7 @@ describe('SimulationService', () => { new Date( Date.parse('2026-08-13T12:00:00.000Z') + clock++, ).toISOString(), - createEventId, + createEventId: deterministicEventIdGenerator(), }); for (let index = 0; index < 48; index += 1) await simulation.executeNextTurn(); @@ -3549,6 +3557,7 @@ describe('SimulationService', () => { }, summary: 'Send directly.', }, + { worldAction: { type: 'wait' }, summary: 'After reset.' }, ]), ); await simulation.executeNextTurn(); @@ -3573,6 +3582,38 @@ describe('SimulationService', () => { directMessagesSent: 0, directMessagesReceived: 0, }); + const afterReset = await simulation.executeNextTurn(); + expect(afterReset.observation.recentPublicMessages).toEqual([]); + expect(afterReset.observation.recentDirectMessages).toEqual([]); + }); + + it('applied World Setup clears bounded observation history', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { + worldAction: { type: 'wait' }, + communication: { + channel: 'public', + message: 'Before applying setup.', + }, + summary: 'Publish.', + }, + { worldAction: { type: 'wait' }, summary: 'After setup.' }, + ]), + ); + await simulation.executeNextTurn(); + const setup = defaultWorldSetupRequest(); + simulation.applyWorldSetup({ + ...setup, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + }); + + const afterSetup = await simulation.executeNextTurn(); + expect(afterSetup.observation.recentPublicMessages).toEqual([]); + expect(afterSetup.observation.recentEvents).toEqual([]); }); it('updates an existing agent and uses the trimmed personality on its next turn', async () => { @@ -4307,14 +4348,16 @@ describe('SimulationService', () => { }); it('cancels an active provider request without mutating or consuming a turn', async () => { + let shouldBlock = true; const provider: AgentProvider = { mode: 'scripted-test', model: 'cancel-test', configured: true, async decide(_observation, _model, options) { - await new Promise((resolve) => { - options?.signal?.addEventListener('abort', () => resolve()); - }); + if (shouldBlock) + await new Promise((resolve) => { + options?.signal?.addEventListener('abort', () => resolve()); + }); return { decision: { worldAction: { type: 'wait' }, summary: 'Too late.' }, metadata: { @@ -4339,6 +4382,10 @@ describe('SimulationService', () => { experiment: { totalCompletedTurns: 0 }, }); expect(simulation.getSnapshot().world).toEqual(before); + shouldBlock = false; + const afterCancellation = await simulation.executeNextTurn(); + expect(afterCancellation.observation.recentEvents).toEqual([]); + expect(afterCancellation.observation.recentPublicMessages).toEqual([]); }); it('resolves models per turn and records between-turn model changes', async () => { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 3b69549..09be8e8 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -23,11 +23,7 @@ import { updateExperimentModelsRequestSchema, updateExperimentBehaviorRequestSchema, h3CellSchema, - RECENT_DIRECT_MESSAGE_LIMIT, - 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, @@ -103,6 +99,7 @@ import { ExperimentMetricAccumulator, } from './experiment-export'; import { geographicDirectionBetweenCells } from './geographic-direction'; +import { ObservationHistory } from './observation-history'; const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; const MAX_TURN_HISTORY = 120; @@ -282,6 +279,7 @@ export class SimulationService { #agentGoals = new Map(); #agentMemories = new Map(); #simulatedPlayerEvents: SimulatedPlayerEvent[] = []; + #observationHistory: ObservationHistory; constructor({ provider, @@ -307,6 +305,7 @@ export class SimulationService { this.#state = toWorldState( createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), ); + this.#observationHistory = new ObservationHistory(this.#state.events); this.#status = provider.configured ? 'paused' : 'configuration-error'; this.#experimentId = experimentIdSchema.parse(this.#createExperimentId()); this.#experimentStartedAt = this.#now(); @@ -436,6 +435,7 @@ export class SimulationService { this.#experimentTurns = []; this.#configurationEvents = []; this.#simulatedPlayerEvents = []; + this.#observationHistory = new ObservationHistory(this.#state.events); this.#initialExperimentAgents = structuredClone([ ...this.#state.agents.values(), ]); @@ -576,6 +576,7 @@ export class SimulationService { this.#experimentTurns = []; this.#configurationEvents = []; this.#simulatedPlayerEvents = []; + this.#observationHistory = new ObservationHistory(this.#state.events); this.#initialExperimentAgents = structuredClone([ ...this.#state.agents.values(), ]); @@ -1187,6 +1188,9 @@ export class SimulationService { ...expirationEvents, ]); } + const committedObservationEvents = state.events.slice( + preTickState.events.length, + ); state = { ...state, events: state.events.slice(-MAX_WORLD_EVENT_HISTORY), @@ -1271,6 +1275,7 @@ export class SimulationService { nextGoals, nextMemories, playerAdvance.events, + committedObservationEvents, ); this.#status = 'paused'; return records; @@ -1306,8 +1311,10 @@ export class SimulationService { goals: Map, memories: Map, playerEvents: SimulatedPlayerEvent[], + observationEvents: WorldEvent[], ): void { this.#state = state; + this.#observationHistory.ingest(observationEvents); this.#completedTickCount = tickNumber; this.#virtualTime = virtualTime; this.#lastTickIntervalMinutes = interval; @@ -1379,7 +1386,13 @@ export class SimulationService { allianceEvents: [], }); this.#pendingFailedTurn = null; - this.#commitCompletedTurn(record, this.#state, agents.length); + this.#commitCompletedTurn( + record, + this.#state, + agents.length, + undefined, + [], + ); this.#status = 'paused'; return record; } @@ -1603,6 +1616,9 @@ export class SimulationService { turnNumber, context, ); + const committedObservationEvents = stateAfterExpiration.events.slice( + preActionState.events.length, + ); const candidateState = { ...stateAfterExpiration, events: stateAfterExpiration.events.slice(-MAX_WORLD_EVENT_HISTORY), @@ -1670,11 +1686,17 @@ export class SimulationService { ); this.#pendingFailedTurn = null; - this.#commitCompletedTurn(record, candidateState, agents.length, { - agentId: agent.id, - goal: appliedGoal.goal, - memoryEntries: appliedMemory.entries, - }); + this.#commitCompletedTurn( + record, + candidateState, + agents.length, + { + agentId: agent.id, + goal: appliedGoal.goal, + memoryEntries: appliedMemory.entries, + }, + committedObservationEvents, + ); this.#status = 'paused'; return record; } catch (error) { @@ -1749,11 +1771,13 @@ export class SimulationService { goal: AgentGoalState | undefined; memoryEntries?: MemoryEntry[]; }, + observationEvents: WorldEvent[] = [], ): void { const turns = [...this.#turns, record].slice(-MAX_TURN_HISTORY); const cursor = (this.#cursor + 1) % agentCount; this.#state = state; + this.#observationHistory.ingest(observationEvents); if (goalCommit?.goal) this.#agentGoals.set(goalCommit.agentId, goalCommit.goal); else if (goalCommit) this.#agentGoals.delete(goalCommit.agentId); @@ -1960,12 +1984,8 @@ export class SimulationService { `${this.#scenario.worldSeed}:${agent.id}:${this.#completedTurnCount + 1}:${b.cell}`, ), ); - const recentMovements = this.#state.events - .filter( - (event): event is Extract => - event.type === 'agent-moved' && event.agentId === agent.id, - ) - .slice(-6) + const recentMovements = this.#observationHistory + .movements(agent.id) .map(({ fromCell, toCell, occurredAt }) => ({ fromCell, toCell, @@ -2017,37 +2037,14 @@ export class SimulationService { a.id.localeCompare(b.id), ) .slice(0, 8); - const recentEvents = this.#state.events - .filter( - ( - event, - ): event is Extract< - WorldEvent, - { - type: - 'agent-moved' | 'hex-infected' | 'hex-captured' | 'agent-waited'; - } - > => - event.type === 'agent-moved' || - event.type === 'hex-infected' || - event.type === 'hex-captured' || - event.type === 'agent-waited', - ) - .slice(-8) - .map((event) => ({ - type: event.type, - agentId: event.agentId, - occurredAt: event.occurredAt, - summary: summarizeEvent(event, this.#state), - })); - const recentPublicMessages = this.#state.events - .filter( - ( - event, - ): event is Extract => - event.type === 'public-message-sent', - ) - .slice(-RECENT_PUBLIC_MESSAGE_LIMIT) + const recentEvents = this.#observationHistory.actions().map((event) => ({ + type: event.type, + agentId: event.agentId, + occurredAt: event.occurredAt, + summary: summarizeEvent(event, this.#state), + })); + const recentPublicMessages = this.#observationHistory + .publicMessages() .map((event) => { const sender = this.#state.agents.get(event.agentId); if (!sender) throw new Error('A public-message sender does not exist.'); @@ -2059,15 +2056,8 @@ export class SimulationService { occurredAt: event.occurredAt, }; }); - const recentDirectMessages = this.#state.events - .filter( - ( - event, - ): event is Extract => - event.type === 'direct-message-sent' && - (event.agentId === agent.id || event.recipientId === agent.id), - ) - .slice(-RECENT_DIRECT_MESSAGE_LIMIT) + const recentDirectMessages = this.#observationHistory + .directMessages(agent.id) .map((event) => { const sender = this.#state.agents.get(event.agentId); const recipient = this.#state.agents.get(event.recipientId); @@ -2085,15 +2075,8 @@ export class SimulationService { distance: event.distance, } as const; }); - const recentAllianceMessages = this.#state.events - .filter( - ( - event, - ): event is Extract => - event.type === 'alliance-message-sent' && - (event.agentId === agent.id || event.recipientIds.includes(agent.id)), - ) - .slice(-RECENT_DIRECT_MESSAGE_LIMIT) + const recentAllianceMessages = this.#observationHistory + .allianceMessages(agent.id) .map((event) => { const sender = this.#state.agents.get(event.agentId); if (!sender) @@ -2107,13 +2090,8 @@ 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) + const recentZeroMessages = this.#observationHistory + .zeroMessages(agent.id) .map((event) => { const sender = this.#state.agents.get(event.agentId); if (!sender) throw new Error('A Zero-message sender does not exist.'); @@ -2126,14 +2104,8 @@ export class SimulationService { occurredAt: event.occurredAt, }; }); - const recentControlChanges = this.#state.events - .filter( - (event): event is Extract => - event.type === 'hex-captured' && - (event.controllerAgentId === agent.id || - event.previousControllerAgentId === agent.id), - ) - .slice(-RECENT_CONTROL_CHANGE_LIMIT) + const recentControlChanges = this.#observationHistory + .controlChanges(agent.id) .map((event) => { const gained = event.controllerAgentId === agent.id; const otherAgentId = gained @@ -2151,9 +2123,13 @@ export class SimulationService { occurredAt: event.occurredAt, }; }); + const completePlayerPressureEvents = [ + ...this.#simulatedPlayerEvents, + ...currentCandidatePlayerEvents, + ]; const recentPlayerThreats = this.#scenario.capabilities .simulatedPlayerPressure - ? this.#state.events + ? completePlayerPressureEvents .filter( ( event, @@ -2181,10 +2157,6 @@ export class SimulationService { affectedOwnTerritory, })) : []; - const completePlayerPressureEvents = [ - ...this.#simulatedPlayerEvents, - ...currentCandidatePlayerEvents, - ]; const patientZeroPlayerThreats = this.#scenario.capabilities .simulatedPlayerPressure ? currentCandidatePlayerEvents @@ -2360,21 +2332,13 @@ export class SimulationService { ], diplomacyFeasibility: [], diplomacySummary: this.#patientZeroDiplomacySummary(), - recentStrategicEvents: this.#state.events - .filter(isAllianceEvent) - .slice(-RECENT_ZERO_STRATEGIC_EVENT_LIMIT) + recentStrategicEvents: this.#observationHistory + .allianceEvents(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), + recentTerritoryChanges: this.#observationHistory.captures(), playerThreatFeed: this.#scenario.capabilities .simulatedPlayerPressure ? { @@ -2403,9 +2367,8 @@ export class SimulationService { outboundAllianceProposals: [ ...(this.#state.pendingAllianceProposals?.values() ?? []), ].filter(({ proposerAgentId }) => proposerAgentId === agent.id), - recentAllianceEvents: this.#state.events - .filter(isAllianceEvent) - .slice(-RECENT_ALLIANCE_EVENT_LIMIT) + recentAllianceEvents: this.#observationHistory + .allianceEvents(RECENT_ALLIANCE_EVENT_LIMIT) .map((event) => ({ event, summary: summarizeAllianceEvent(event, this.#state), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3363a1..7cfffb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,6 +116,19 @@ range bypass. Patient Zero receives no extra movement, action, infection, capture, ownership, or alliance authority. Every agent observation is built from one frozen pre-tick snapshot. +The authoritative world's newest 120 events remain a bounded operator/display +feed. Agent observations do not depend on that mixed feed for their promised +factual windows. The Game API separately retains bounded movement, action, +communication, control-change, alliance-lifecycle, and capture ledgers, +including participant-specific private-message histories. These ledgers accept +only newly committed engine events: simultaneous ticks carry the complete +untrimmed event batch into commit and ingest it once after the final +cancellation check while separately capping the display feed; legacy turns +ingest only successful committed events, and provider failures, retries awaiting resolution, skips, and +cancellations add no facts. World reset and applied World Setup reinitialize +the ledgers; model, personality, and behavior configuration changes preserve +them. + Patient Zero's global diplomacy context is a fixed-cap sparse summary of authoritative eligible pairs, acceptable proposals, leave availability, aggregate blocker counts, and prioritized blocker examples. Deterministic diff --git a/docs/TESTING.md b/docs/TESTING.md index 99e14cd..5cc70d9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -71,6 +71,17 @@ 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. +Observation-history tests independently churn more than the 120-event World +Lab display bound and verify chronological limits for per-agent movement and +recently-occupied facts, global actions and public messages, +participant-private direct/alliance/Zero messages, control changes, alliance +lifecycle, and Patient Zero capture history. Service commit paths ingest only +newly committed facts from the complete pre-display-truncation batch; +cancellation, unresolved failure, retry setup, and skip +paths cannot add or duplicate them. Reset and applied World Setup reinitialize +the ledgers. Cleaner locality continues to use its dedicated committed event +ledger plus the current candidate interval. + Legacy sequential recovery remains covered only as schema-v9 compatibility. Tick tests instead verify that an exhausted per-agent repair or transient retry becomes a final lost-tick record while sibling records commit, and that whole-tick cancellation commits nothing. No live provider call is made. Default validation is deterministic and offline except dependency/browser installation and optional basemap requests during browser rendering. No default test or GitHub Actions job contacts OpenRouter.