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
1 change: 1 addition & 0 deletions apps/game-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js",
"compare:offline": "node --import tsx src/swarm-comparison-cli.ts",
"compare:live": "node --import tsx src/live-swarm-comparison-cli.ts",
"diagnose:swarm": "node --import tsx src/swarm-diagnostics-cli.ts",
"dev": "tsx watch src/server.ts",
"lint": "eslint .",
"start": "node dist/server.js",
Expand Down
16 changes: 14 additions & 2 deletions apps/game-api/src/simulation-service.swarm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ describe('zero-swarm SimulationService tick', () => {
expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(8);
});

it('uses deterministic neutral directives and Zero wait when planning fails', async () => {
it('uses one billed Zero attempt and deterministic legal expansion when the first plan fails', async () => {
const simulation = setup(
new InspectingPlanner(true),
new ScriptedReflexProvider(
Expand All @@ -420,8 +420,20 @@ describe('zero-swarm SimulationService tick', () => {
expect(tick?.planSource).toBe('deterministic-fallback');
expect(tick?.zeroAction).toEqual({ type: 'wait' });
expect(
tick?.workers.every(({ directive }) => directive.mission === 'hold'),
tick?.workers.every(
({ directive, source }) =>
directive.mission === 'expand' && source === 'deterministic-fallback',
),
).toBe(true);
expect(
tick?.workers.some(
({ action, actionResult }) =>
action?.type === 'infect' && actionResult?.accepted === true,
),
).toBe(true);
expect(
simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted,
).toBe(1);
});

it('reuses unexpired directives for four ticks, then replans on the fifth tick', async () => {
Expand Down
78 changes: 54 additions & 24 deletions apps/game-api/src/simulation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ function chooseDeterministicWorkerAction(
};
}

function selectNeutralFallbackCandidate(
compiled: CompiledReflexObservation,
state: WorldState,
): string {
const choices = [...compiled.actions];
return (choices.find(([, action]) => action.type === 'infect') ??
choices.find(([, action]) => action.type === 'capture') ??
choices.find(
([, action]) =>
action.type === 'move' &&
state.hexes.get(action.targetCell)?.state === 'open',
) ??
choices.find(([, action]) => action.type === 'wait'))![0];
}

export function selectMostRecentPatientZeroThreats<
T extends { eventId: string; occurredAt: string },
>(events: readonly T[]): T[] {
Expand Down Expand Up @@ -1666,6 +1681,7 @@ export class SimulationService {
{
signal: controller.signal,
deadlineAtMs,
reasoningProfile: resolvedZero.reasoningProfile,
beginAttempt: (kind) => {
const startedAt = this.#now();
const permit =
Expand Down Expand Up @@ -1756,26 +1772,39 @@ export class SimulationService {
? ('success' as const)
: ('unknown' as const),
};
const choice = this.#deterministicWorkerCandidateSelector
? chooseDeterministicWorkerAction(
compileReflexObservation(candidate, directive, history),
this.#deterministicWorkerCandidateSelector,
)
: await chooseReflexWorldAction(
candidate,
directive,
this.#reflexProvider,
{
history,
signal: controller.signal,
deadlineAtMs,
accounting: this.#attemptAccounting,
initialPermitReserved: true,
intendedTickNumber: tickNumber,
intendedTurnNumber: tickTurnBase + order.indexOf(worker.id) + 1,
now: this.#now,
},
);
const retainedDirective = this.#lastValidSwarmPlan?.directives.some(
(previous) =>
previous.agentId === worker.id &&
previous.expiresAtTick >= tickNumber,
);
const choice =
planSource === 'deterministic-fallback' && !retainedDirective
? chooseDeterministicWorkerAction(
compileReflexObservation(candidate, directive, history),
(compiled) =>
selectNeutralFallbackCandidate(compiled, candidate),
)
: this.#deterministicWorkerCandidateSelector
? chooseDeterministicWorkerAction(
compileReflexObservation(candidate, directive, history),
this.#deterministicWorkerCandidateSelector,
)
: await chooseReflexWorldAction(
candidate,
directive,
this.#reflexProvider,
{
history,
signal: controller.signal,
deadlineAtMs,
accounting: this.#attemptAccounting,
initialPermitReserved: true,
intendedTickNumber: tickNumber,
intendedTurnNumber:
tickTurnBase + order.indexOf(worker.id) + 1,
now: this.#now,
},
);
selected.set(worker.id, choice);
}
if (controller.signal.aborted) throw new SimulationTurnCancelledError();
Expand Down Expand Up @@ -2932,8 +2961,8 @@ export class SimulationService {
) ?? {
id: `neutral-${tickNumber}-${worker.id}`,
agentId: worker.id,
mission: 'hold' as const,
targetCell: state.agents.get(worker.id)!.currentCell,
mission: 'expand' as const,
targetCell: null,
priority: 'normal' as const,
riskTolerance: 'low' as const,
issuedAtTick: tickNumber,
Expand All @@ -2946,8 +2975,9 @@ export class SimulationService {
if (waitIndex < 0)
throw new Error('The engine must provide a legal wait action.');
return swarmPlanSchema.parse({
strategySummary:
'Maintain legal local positions while strategic planning is unavailable.',
strategySummary: this.#lastValidSwarmPlan
? 'Continue prior valid directives while strategic planning is unavailable.'
: 'Use deterministic local expansion while strategic planning is unavailable.',
directives,
zeroActionCandidateId: `zero_action_${waitIndex}`,
});
Expand Down
80 changes: 80 additions & 0 deletions apps/game-api/src/swarm-diagnostics-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import type { SimulationSnapshot } from '@hexzero/shared';
import { summarizeSwarmSnapshot } from './swarm-diagnostics-cli';

describe('swarm diagnostic projection', () => {
it('shows why a stationary swarm has no player pressure and fallback actions', () => {
const snapshot = {
tickNumber: 1,
scenario: {
cognitionMode: 'zero-swarm-v1',
patientZeroAgentId: 'zero',
simulatedPlayer: { enabled: false, profile: 'casual-cleaner' },
},
world: { simulatedPlayer: null, hexes: [] },
resolvedModels: [{ agentId: 'zero', modelId: 'google/gemini-test' }],
swarmProviderStatus: {
plannerMode: 'openrouter-swarm',
plannerConfigured: true,
reflexMode: 'typesafe-jev',
reflexConfigured: true,
},
experiment: {
attemptAccounting: {
attemptsStarted: 8,
attemptsFinalized: 8,
attemptsWithUnknownCost: 7,
knownFinalizedCostCredits: '0.02',
committedCreditExposure: '0.09',
reservationCreditsPerAttempt: '0.01',
},
},
swarmTicks: [
{
tickNumber: 1,
planSource: 'deterministic-fallback',
plannerFailure: { code: 'malformed-response' },
zeroAction: { type: 'wait' },
zeroActionResult: { accepted: true },
workers: [
{
agentId: 'worker',
action: { type: 'wait' },
source: 'deterministic-fallback',
failure: { code: 'timeout', message: 'Jev timed out.' },
},
],
},
],
} as unknown as SimulationSnapshot;
expect(summarizeSwarmSnapshot(snapshot)).toMatchObject({
tick: 1,
infectedCells: 0,
player: { enabled: false, active: false, movements: 0 },
attempts: {
started: 8,
finalized: 8,
unknownCost: 7,
providerReportedCostCredits: '0.02',
admissionExposureCredits: '0.09',
reservePerAttemptCredits: '0.01',
},
recentTicks: [
{
planSource: 'deterministic-fallback',
plannerFailureCode: 'malformed-response',
zeroAction: 'wait',
workerActions: { wait: 1 },
workerFallbacks: 1,
workerFailureCodes: ['timeout'],
workerFailures: [
{ agentId: 'worker', code: 'timeout', message: 'Jev timed out.' },
],
},
],
});
expect(JSON.stringify(summarizeSwarmSnapshot(snapshot))).not.toContain(
'apiKey',
);
});
});
95 changes: 95 additions & 0 deletions apps/game-api/src/swarm-diagnostics-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import {
simulationSnapshotSchema,
type SimulationSnapshot,
type WorldAction,
} from '@hexzero/shared';

/** A small, safe projection of an already committed local experiment. */
export function summarizeSwarmSnapshot(snapshot: SimulationSnapshot) {
const zeroId = snapshot.scenario.patientZeroAgentId;
const player = snapshot.world.simulatedPlayer;
const attempts = snapshot.experiment.attemptAccounting;
const actionCounts = (actions: readonly (WorldAction | undefined)[]) => ({
move: actions.filter((action) => action?.type === 'move').length,
infect: actions.filter((action) => action?.type === 'infect').length,
capture: actions.filter((action) => action?.type === 'capture').length,
wait: actions.filter((action) => action?.type === 'wait').length,
});
return {
tick: snapshot.tickNumber,
mode: snapshot.scenario.cognitionMode,
infectedCells: snapshot.world.hexes.filter(
({ state }) => state === 'infected',
).length,
zeroModel:
snapshot.resolvedModels.find(({ agentId }) => agentId === zeroId)
?.modelId ?? null,
player: {
enabled: snapshot.scenario.simulatedPlayer.enabled,
profile: snapshot.scenario.simulatedPlayer.profile,
active: Boolean(player),
movements: player?.metrics.movements ?? 0,
cleaned: player?.metrics.cellsDisinfected ?? 0,
blockedCleans: player?.metrics.blockedDisinfections ?? 0,
},
providers: snapshot.swarmProviderStatus ?? null,
attempts: {
started: attempts.attemptsStarted,
finalized: attempts.attemptsFinalized,
unknownCost: attempts.attemptsWithUnknownCost,
providerReportedCostCredits: attempts.knownFinalizedCostCredits,
admissionExposureCredits: attempts.committedCreditExposure,
reservePerAttemptCredits: attempts.reservationCreditsPerAttempt,
},
recentTicks: (snapshot.swarmTicks ?? []).slice(-10).map((tick) => ({
tick: tick.tickNumber,
planSource: tick.planSource,
plannerFailureCode: tick.plannerFailure?.code ?? null,
zeroAction: tick.zeroAction?.type ?? null,
zeroAccepted: tick.zeroActionResult?.accepted ?? null,
workerActions: actionCounts(tick.workers.map(({ action }) => action)),
jevDecisions: tick.workers.filter(({ source }) => source === 'jev-reflex')
.length,
workerFallbacks: tick.workers.filter(
({ source }) => source === 'deterministic-fallback',
).length,
workerFailureCodes: tick.workers.flatMap(({ failure }) =>
failure ? [failure.code] : [],
),
workerFailures: tick.workers.flatMap(({ agentId, failure }) =>
failure
? [{ agentId, code: failure.code, message: failure.message }]
: [],
),
})),
};
}

async function main(): Promise<void> {
const port = process.env.PORT ?? '8787';
if (!/^\d{1,5}$/.test(port) || Number(port) > 65_535)
throw new Error('PORT must be a valid local TCP port.');
const url = `http://127.0.0.1:${port}/api/simulation`;
let response: Response;
try {
response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
} catch {
throw new Error(
`Game API is unavailable at ${url}. Start pnpm dev and leave it running in another terminal.`,
);
}
if (!response.ok) throw new Error('Game API did not return a snapshot.');
const snapshot = simulationSnapshotSchema.parse(await response.json());
process.stdout.write(
`${JSON.stringify(summarizeSwarmSnapshot(snapshot), null, 2)}\n`,
);
}

if (import.meta.url === `file://${process.argv[1]}`)
void main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : 'Swarm diagnostic failed.'}\n`,
);
process.exitCode = 1;
});
32 changes: 32 additions & 0 deletions apps/world-lab/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,12 @@ h2 {
overflow: hidden;
border-bottom: 0;
}
.bottom-dock.activity-dock > .swarm-activity {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.bottom-dock .event-panel {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -2152,6 +2158,32 @@ summary:focus-visible {
text-overflow: ellipsis;
white-space: nowrap;
}
.execution-mode-button {
display: grid;
justify-items: start;
min-width: 0;
padding: 5px 8px;
text-align: left;
background: color-mix(in srgb, var(--blackberry-cream) 25%, var(--surface));
}
.execution-mode-button strong {
color: var(--vanilla-custard);
font-size: 0.72rem;
white-space: nowrap;
}
.execution-mode-button .test-provider-summary {
width: 100%;
}
.swarm-diagnostic {
margin: 8px 0;
padding: 8px 10px;
border: 1px solid var(--line);
background: var(--surface-raised);
font-size: 0.75rem;
}
.swarm-diagnostic p + p {
margin-top: 4px;
}
.command-alert {
border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
background: color-mix(
Expand Down
Loading
Loading