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
76 changes: 57 additions & 19 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,12 @@ export class ExperimentMetricAccumulator {
turn.outcome === 'accepted' &&
turn.worldActionResult.event.type === 'hex-captured'
) {
const displaced = this.#records.get(
turn.worldActionResult.event.previousControllerAgentId,
);
const previousControllerAgentId =
turn.worldActionResult.event.previousControllerAgentId;
const displaced =
previousControllerAgentId === null
? undefined
: this.#records.get(previousControllerAgentId);
if (displaced) displaced.territoryLostThroughCapture += 1;
}
}
Expand Down Expand Up @@ -758,9 +761,28 @@ export function createExperimentExport(
.map(({ tickNumber }) => tickNumber)
.filter((tick): tick is number => tick !== undefined),
...(exportedSwarmTicks ?? []).map(({ tickNumber }) => tickNumber),
...source.simulatedPlayerEvents
.filter(
(event) =>
(request.agents.mode === 'all' &&
request.turns.mode === 'entire-retained') ||
(event.type === 'simulated-player-agent-captured' &&
selectedSet.has(event.capturedAgentId)),
)
.map(({ originatingTick }) => originatingTick),
]);
const selectedAgents = source.currentAgents
.filter(({ id }) => selectedSet.has(id))
const currentAgentsById = new Map(
source.currentAgents.map((agent) => [agent.id, agent]),
);
const initialAgentsById = new Map(
source.initialAgents.map((agent) => [agent.id, agent]),
);
const selectedAgents = selectedAgentIds
.map(
(agentId) =>
currentAgentsById.get(agentId) ?? initialAgentsById.get(agentId),
)
.filter((agent): agent is Agent => agent !== undefined)
.map((agent) =>
include.personality
? structuredClone(agent)
Expand Down Expand Up @@ -816,12 +838,22 @@ export function createExperimentExport(
lastMatchingTurn: filtered.at(-1)?.turnNumber,
},
agents: selectedAgents,
currentGoals: source.agentGoals
.filter(({ agentId }) => selectedSet.has(agentId))
.map((entry) => structuredClone(entry)),
currentMemories: source.agentMemories
.filter(({ agentId }) => selectedSet.has(agentId))
.map((entry) => structuredClone(entry)),
currentGoals: selectedAgentIds.map((agentId) =>
structuredClone(
source.agentGoals.find((entry) => entry.agentId === agentId) ?? {
agentId,
goal: null,
},
),
),
currentMemories: selectedAgentIds.map((agentId) =>
structuredClone(
source.agentMemories.find((entry) => entry.agentId === agentId) ?? {
agentId,
entries: [],
},
),
),
configurationEvents: source.configurationEvents
.filter((event) =>
'type' in event
Expand Down Expand Up @@ -919,7 +951,7 @@ function exportWorldState(world: WorldSnapshot): ExperimentExportWorldState {
function currentTerritory(world: WorldSnapshot, agents: readonly Agent[]) {
const counts = new Map<AgentId, number>(agents.map(({ id }) => [id, 0]));
for (const hex of world.hexes) {
if (hex.state === 'infected')
if (hex.state === 'infected' && hex.controllerAgentId !== null)
counts.set(
hex.controllerAgentId,
(counts.get(hex.controllerAgentId) ?? 0) + 1,
Expand Down Expand Up @@ -1040,11 +1072,12 @@ function resolveAgentIds(
source: ExperimentSource,
request: ExperimentExportRequest,
): AgentId[] {
const known = new Set(source.currentAgents.map(({ id }) => id));
const known = new Set([
...source.initialAgents.map(({ id }) => id),
...source.currentAgents.map(({ id }) => id),
]);
const selected =
request.agents.mode === 'all'
? source.currentAgents.map(({ id }) => id)
: request.agents.agentIds;
request.agents.mode === 'all' ? [...known] : request.agents.agentIds;
if (selected.some((id) => !known.has(id))) {
throw new ExperimentExportValidationError(
'unknown_agent',
Expand Down Expand Up @@ -1188,7 +1221,10 @@ function filterControlChanges(
turn.outcome !== 'accepted' ||
turn.worldActionResult.event.type !== 'hex-captured' ||
(!selected.has(turn.worldActionResult.event.controllerAgentId) &&
!selected.has(turn.worldActionResult.event.previousControllerAgentId))
(turn.worldActionResult.event.previousControllerAgentId === null ||
!selected.has(
turn.worldActionResult.event.previousControllerAgentId,
)))
)
return [];
return [
Expand Down Expand Up @@ -1733,8 +1769,10 @@ export function calculateExperimentMetrics(
({ previousControllerAgentId }) =>
previousControllerAgentId === agentId,
).length
: relevantControlChanges.filter(({ previousControllerAgentId }) =>
scopedAgentIds.includes(previousControllerAgentId),
: relevantControlChanges.filter(
({ previousControllerAgentId }) =>
previousControllerAgentId !== null &&
scopedAgentIds.includes(previousControllerAgentId),
).length,
...communicationMetrics(relevantCommunications, scopedAgentIds, agentId),
mostRecentZeroDirective,
Expand Down
14 changes: 8 additions & 6 deletions apps/game-api/src/observation-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ export class ObservationHistory {
event,
RECENT_CONTROL_CHANGE_LIMIT,
);
appendFor(
this.#controlChanges,
event.previousControllerAgentId,
event,
RECENT_CONTROL_CHANGE_LIMIT,
);
if (event.previousControllerAgentId !== null)
appendFor(
this.#controlChanges,
event.previousControllerAgentId,
event,
RECENT_CONTROL_CHANGE_LIMIT,
);
break;
case 'public-message-sent':
this.#publicMessages = append(
Expand Down Expand Up @@ -135,6 +136,7 @@ export class ObservationHistory {
case 'simulated-player-moved':
case 'hex-disinfected':
case 'simulated-player-clean-blocked':
case 'simulated-player-agent-captured':
break;
}
}
Expand Down
23 changes: 23 additions & 0 deletions apps/game-api/src/reflex-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ describe('zero-swarm reflex execution seam', () => {
expect(applied.state.agents.get(agent.id)?.currentCell).toBe(targetCell);
});

it('passes bounded authoritative capture facts to Jev without player route data', () => {
const { state, directive } = fixture();
const compiled = compileReflexObservation(state, directive, {
captureAlerts: [
{
capturedAgentId: [...state.agents.keys()][0]!,
cell: directive.targetCell!,
originatingTick: 4,
abandonedCellCount: 2,
},
],
});
expect(compiled.observation.captureAlerts).toEqual([
{
capturedAgentId: [...state.agents.keys()][0]!,
cell: directive.targetCell,
originatingTick: 4,
abandonedCellCount: 2,
},
]);
expect(compiled.observation).not.toHaveProperty('simulatedPlayer');
});

it('retains a completed provider attempt even when no world state is committed', async () => {
const { state, directive } = fixture();
const accounting = new AttemptAccounting(10);
Expand Down
5 changes: 5 additions & 0 deletions apps/game-api/src/reflex-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
reflexObservationSchema,
swarmDirectiveSchema,
type H3Cell,
type CaptureAlert,
type ProviderFailure,
type ProviderMetadata,
type ReflexDecision,
Expand All @@ -28,6 +29,7 @@ export interface ReflexLocalHistory {
recentCleanedCells?: readonly H3Cell[];
territoryDelta?: number;
recentActionOutcome?: 'success' | 'rejected' | 'unknown';
captureAlerts?: readonly CaptureAlert[];
}

export interface CompiledReflexObservation {
Expand Down Expand Up @@ -165,6 +167,9 @@ export function compileReflexObservation(
recentActionOutcome: history.recentActionOutcome ?? 'unknown',
},
relevantRecentFacts: [],
...(history.captureAlerts?.length
? { captureAlerts: [...history.captureAlerts].slice(-4) }
: {}),
candidates,
});
return { observation, actions };
Expand Down
Loading
Loading