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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ coordinator; the deterministic default is the first default-roster agent.
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-v8`; the objective remains `durable-influence-v2`. For every
`text-flat-json-v8`; the capability-gated objective is `durable-influence-v3`.
Without simulated-player pressure it uses a v2-compatible objective and never
fabricates player threats. For every
agent, including Patient Zero, no message is the normal choice unless a message
adds new decision-relevant value; routine action narration and filler are
explicitly discouraged.
Expand Down Expand Up @@ -92,9 +94,11 @@ The command reads only `OPENROUTER_API_KEY` from the repository-root `.env`; its

## Development map source

The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders the same deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. World Setup previews and applies resolution 8–11 scenarios with 1–32 agents, radius at most 40, at most 5,000 actual generated cells, and a 12 km default physical communication range. Schema-v9 exports preserve the authoritative scenario and `durable-influence-v2` attribution. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution.
The compatible default centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders the same deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. World Setup previews and applies resolution 8–11 scenarios with 1–32 agents, radius at most 40, at most 5,000 actual generated cells, and a 12 km default physical communication range. It may optionally add one seeded deterministic casual cleaner. Legacy exports preserve their authoritative objective attribution. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution.

Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, restartable world persistence, and autonomous scheduling remain deferred.
Alliance leadership, merging, custom metadata, combat systems, relationship
scores, group chat, real-player GPS/capture, restartable world persistence, and
autonomous scheduling remain deferred.

Formal alliances may grow to the entire configured roster and active worlds may
use every feasible roster partition. Accessible alliance colors are
Expand Down
44 changes: 36 additions & 8 deletions apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type ExperimentTickSummary,
type AgentGoalState,
type MemoryEntry,
type SimulatedPlayerEvent,
} from '@hexzero/shared';

export interface ExperimentSource {
Expand All @@ -54,6 +55,7 @@ export interface ExperimentSource {
scenario: AppliedScenario;
agentGoals: readonly { agentId: AgentId; goal: AgentGoalState | null }[];
agentMemories: readonly { agentId: AgentId; entries: MemoryEntry[] }[];
simulatedPlayerEvents: readonly SimulatedPlayerEvent[];
}

export class ExperimentExportValidationError extends Error {
Expand Down Expand Up @@ -778,6 +780,12 @@ export function createExperimentExport(
matchingCommunicationCount: communications.length,
matchingControlChangeCount: controlChanges.length,
matchingDiplomacyEventCount: allianceEvents.length,
matchingSimulatedPlayerEventCount: source.simulatedPlayerEvents.filter(
(event) =>
new Set(
filtered.map(({ tickNumber }) => tickNumber).filter(Boolean),
).has(event.originatingTick),
).length,
firstMatchingTurn: filtered[0]?.turnNumber,
lastMatchingTurn: filtered.at(-1)?.turnNumber,
},
Expand Down Expand Up @@ -813,6 +821,12 @@ export function createExperimentExport(
source.currentWorld,
source.currentAgents,
),
simulatedPlayerMetrics: source.currentWorld.simulatedPlayer
?.metrics ?? {
movements: 0,
cellsDisinfected: 0,
blockedDisinfections: 0,
},
}
: {}),
...(include.initialWorld
Expand All @@ -823,14 +837,22 @@ export function createExperimentExport(
: {}),
...(request.level === 'full-safe'
? {
worldEvents: filtered.flatMap((turn) => {
if (
turn.outcome !== 'accepted' ||
turn.worldActionResult.event.type === 'hex-captured'
)
return [];
return [structuredClone(turn.worldActionResult.event)];
}),
worldEvents: [
...filtered.flatMap((turn) => {
if (
turn.outcome !== 'accepted' ||
turn.worldActionResult.event.type === 'hex-captured'
)
return [];
return [structuredClone(turn.worldActionResult.event)];
}),
...source.simulatedPlayerEvents.filter((event) => {
const selectedTicks = new Set(
filtered.map(({ tickNumber }) => tickNumber).filter(Boolean),
);
return selectedTicks.has(event.originatingTick);
}),
],
}
: {}),
...(include.communications
Expand All @@ -855,6 +877,7 @@ function exportWorldState(world: WorldSnapshot): ExperimentExportWorldState {
agents: structuredClone(world.agents),
alliances: structuredClone(world.alliances),
pendingAllianceProposals: structuredClone(world.pendingAllianceProposals),
simulatedPlayer: structuredClone(world.simulatedPlayer),
};
}

Expand Down Expand Up @@ -1329,6 +1352,11 @@ function exportTurn(
delete observation.recentDirectMessages;
if (custom && !custom.recentControlChanges)
delete observation.recentControlChanges;
if (custom && !custom.recentControlChanges && observation.playerPressure)
observation.playerPressure = {
...observation.playerPressure,
recentThreats: [],
};
base.observation = observation;
}
if (
Expand Down
184 changes: 183 additions & 1 deletion apps/game-api/src/simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,100 @@ function exportRequest(level: 'minimal' | 'standard' | 'full-safe' | 'custom') {
}

describe('SimulationService', () => {
it('commits cleaner pressure before frozen observations without exposing live GPS', async () => {
const seen: AgentObservation[] = [];
const simulation = service({
mode: 'scripted-test',
model: 'deterministic-script',
configured: true,
async decide(observation): Promise<ProviderDecision> {
seen.push(structuredClone(observation));
return {
decision: {
worldAction:
observation.currentCell.state === 'open'
? { type: 'infect' }
: {
type: 'move',
targetCell:
observation.actionAvailability.moveTargetCellIds[0]!,
},
goalRevision: observation.currentGoal
? { operation: 'keep' }
: {
operation: 'establish',
longTermGoal: 'Preserve territory under cleaner pressure.',
shortTermGoal: 'Respond to authoritative local evidence.',
planSummary: 'Expand and adapt to observed losses.',
reason: 'Player pressure is enabled.',
},
memoryOperation: { operation: 'keep' },
summary: 'Take a deterministic legal action.',
},
metadata: {
provider: 'scripted-test',
model: 'deterministic-script',
latencyMs: 0,
},
};
},
});
const setup = defaultWorldSetupRequest();
simulation.applyWorldSetup({
...setup,
objectiveVersion: 'durable-influence-v3',
modelConfiguration: {
...setup.modelConfiguration,
globalModelId: 'deterministic-script',
},
capabilities: { ...setup.capabilities, simulatedPlayerPressure: true },
simulatedPlayer: {
enabled: true,
profile: 'casual-cleaner',
seed: 'pressure-test',
},
});
for (let tick = 0; tick < 12; tick += 1) await simulation.executeNextTick();
const snapshot = simulation.getSnapshot();
expect(
snapshot.experiment.simulatedPlayerMetrics.cellsDisinfected,
).toBeGreaterThan(0);
const pressured = seen.filter(
({ playerPressure }) => playerPressure.recentThreats.length > 0,
);
expect(pressured.length).toBeGreaterThan(0);
expect(pressured[0]!.playerPressure).not.toHaveProperty('currentCell');
expect(snapshot.world.simulatedPlayer?.currentCell).toBeTruthy();
const redacted = simulation.generateExperimentExport({
...exportRequest('custom'),
custom: {
turnObservations: true,
personalityTextHistory: false,
nearbyAgents: false,
recentEvents: false,
recentPublicMessages: false,
recentDirectMessages: false,
recentControlChanges: false,
validationDetails: false,
resultingEvents: false,
providerUsageMetadata: false,
initialWorldState: false,
currentWorldState: false,
computedMetrics: false,
communications: false,
controlChanges: false,
},
});
expect(
redacted.turns.every(
({ observation }) =>
observation?.playerPressure?.enabled === true &&
observation.playerPressure.recentThreats?.length === 0,
),
).toBe(true);
expect(redacted).not.toHaveProperty('simulatedPlayerMetrics');
});

it('keeps compact memory canonical and rejects full or missing operations independently', () => {
const agent = agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e');
const remembered = applyMemoryOperation(
Expand Down Expand Up @@ -499,6 +593,40 @@ describe('SimulationService', () => {
);
});

it('requires objective attribution to match simulated-player pressure', () => {
const simulation = service(
new ScriptedAgentProvider([
{ worldAction: { type: 'wait' }, summary: 'Wait.' },
]),
);
const setup = defaultWorldSetupRequest();

expect(() =>
simulation.applyWorldSetup({
...setup,
objectiveVersion: 'durable-influence-v3',
}),
).toThrow(SimulationValidationError);
expect(() =>
simulation.applyWorldSetup({
...setup,
capabilities: {
...setup.capabilities,
simulatedPlayerPressure: true,
},
simulatedPlayer: {
enabled: true,
profile: 'casual-cleaner',
seed: 'mismatched-pressure',
},
}),
).toThrow(SimulationValidationError);

expect(simulation.getSnapshot().scenario.objectiveVersion).toBe(
'durable-influence-v2',
);
});

it('prioritizes free diplomacy blocker examples before allied relationships', () => {
const base = toWorldState(createDevelopmentWorld({ generatedAt: now() }));
const agents = [...base.agents.values()];
Expand Down Expand Up @@ -919,6 +1047,21 @@ describe('SimulationService', () => {
});
},
});
const setup = defaultWorldSetupRequest();
simulation.applyWorldSetup({
...setup,
objectiveVersion: 'durable-influence-v3',
modelConfiguration: {
...setup.modelConfiguration,
globalModelId: 'deterministic-script',
},
capabilities: { ...setup.capabilities, simulatedPlayerPressure: true },
simulatedPlayer: {
enabled: true,
profile: 'casual-cleaner',
seed: 'cancel-pressure',
},
});
const before = simulation.getSnapshot();
const pending = simulation.executeNextTick();
await requestStarted;
Expand Down Expand Up @@ -1067,12 +1210,39 @@ describe('SimulationService', () => {
],
});
expect(retained.turns).toHaveLength(8);
expect(retained.schemaVersion).toBe(10);
expect(new Set(retained.turns.map(({ tickNumber }) => tickNumber))).toEqual(
new Set([2]),
);
expect(experimentExportDocumentSchema.safeParse(retained).success).toBe(
true,
);
const legacyV10 = structuredClone(retained);
delete legacyV10.simulatedPlayerMetrics;
expect(
experimentExportDocumentSchema.parse(legacyV10).simulatedPlayerMetrics,
).toEqual({
movements: 0,
cellsDisinfected: 0,
blockedDisinfections: 0,
});
const enabledWithoutMetrics = structuredClone(legacyV10);
enabledWithoutMetrics.experiment.scenario = {
...enabledWithoutMetrics.experiment.scenario!,
objectiveVersion: 'durable-influence-v3',
capabilities: {
...enabledWithoutMetrics.experiment.scenario!.capabilities,
simulatedPlayerPressure: true,
},
simulatedPlayer: {
enabled: true,
profile: 'casual-cleaner',
seed: 'missing-metrics',
},
};
expect(
experimentExportDocumentSchema.safeParse(enabledWithoutMetrics).success,
).toBe(false);
expect(
experimentExportDocumentSchema.safeParse({
...retained,
Expand Down Expand Up @@ -2316,7 +2486,7 @@ describe('SimulationService', () => {
expect(oneAgent.worldEvents).toHaveLength(1);
expect(
oneAgent.worldEvents?.every(
({ agentId }) => agentId === selectedAgent.id,
(event) => 'agentId' in event && event.agentId === selectedAgent.id,
),
).toBe(true);
expect(oneAgent.turns.map(({ turnNumber }) => turnNumber)).toEqual([1]);
Expand Down Expand Up @@ -2583,6 +2753,18 @@ describe('SimulationService', () => {
expect(custom).not.toHaveProperty('communications');
expect(custom).not.toHaveProperty('controlChanges');
expect(custom).not.toHaveProperty('metrics');
expect(custom).not.toHaveProperty('simulatedPlayerMetrics');
expect(minimal).toHaveProperty('simulatedPlayerMetrics');
expect(
experimentExportDocumentSchema.safeParse({
...custom,
simulatedPlayerMetrics: {
movements: 0,
cellsDisinfected: 0,
blockedDisinfections: 0,
},
}).success,
).toBe(false);
expect(custom.turns[0]).not.toHaveProperty('provider');
minimal.turns[0]!.outcome = 'rejected';
expect(
Expand Down
Loading
Loading