diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index 1e8d94b..fe576ee 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -81,6 +81,23 @@ describe('zero-swarm reflex execution seam', () => { expect(applied.state.agents.get(agent.id)?.currentCell).toBe(targetCell); }); + it('reports at-target after a worker reaches its directive target', () => { + const { state, agent, directive, targetCell } = fixture(); + const moved = applyWorldAction(state, agent.id, { + type: 'move', + targetCell, + }); + expect(moved.result.accepted).toBe(true); + + const compiled = compileReflexObservation(moved.state, directive, { + previousCell: agent.currentCell, + }); + + expect(compiled.observation.currentSituation.directiveProgress).toBe( + 'at-target', + ); + }); + it('retains only the four most recent authoritative capture alerts', () => { const { state, directive } = fixture(); const capturedAgentId = [...state.agents.keys()][0]!; diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts index 162735e..47511ed 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -133,14 +133,18 @@ export function compileReflexObservation( (distance(action.targetCell, directive.targetCell!) ?? Infinity) < currentDistance, ); - const directiveProgress = + let directiveProgress: 'advancing' | 'at-target' | 'stalled' | 'blocked' = + 'stalled'; + if (directive.targetCell && currentDistance === 0) + directiveProgress = 'at-target'; + else if ( previousDistance !== null && currentDistance !== null && currentDistance < previousDistance - ? ('advancing' as const) - : directive.targetCell && currentDistance !== 0 && !hasForwardMove - ? ('blocked' as const) - : ('stalled' as const); + ) + directiveProgress = 'advancing'; + else if (directive.targetCell && currentDistance !== 0 && !hasForwardMove) + directiveProgress = 'blocked'; const nearbyCleaned = (history.recentCleanedCells ?? []) .slice(-6) .map((cell) => distance(current, h3CellSchema.parse(cell))) diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index 3be4497..9052c0e 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -1,3 +1,4 @@ +import { gridDistance } from 'h3-js'; import { describe, expect, it } from 'vitest'; import { BrowserTestAgentProvider, @@ -43,6 +44,7 @@ class InspectingPlanner implements SwarmPlanner { constructor( private readonly failure: boolean | number = false, private readonly directiveLifetime = 5, + private readonly targetDistance = 0, ) {} async plan( observation: ZeroStrategicObservation, @@ -73,16 +75,31 @@ class InspectingPlanner implements SwarmPlanner { )!.id, directives: observation.agents .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent, index) => ({ - id: `directive-${observation.tickNumber}-${index}`, - agentId: agent.agentId, - mission: 'hold', - targetCell: agent.position, - priority: 'normal', - riskTolerance: 'low', - issuedAtTick: observation.tickNumber, - expiresAtTick: observation.tickNumber + this.directiveLifetime, - })), + .map((agent, index) => { + const targetCell = + this.targetDistance === 0 + ? agent.position + : observation.strategicTargetCells.find( + (cell) => + cell !== agent.position && + gridDistance(cell, agent.position) === + this.targetDistance, + ); + if (!targetCell) + throw new Error( + `No strategic target is ${this.targetDistance} cells from ${agent.agentId}.`, + ); + return { + id: `directive-${observation.tickNumber}-${index}`, + agentId: agent.agentId, + mission: 'hold', + targetCell, + priority: 'normal', + riskTolerance: 'low', + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + this.directiveLifetime, + }; + }), }, metadata: { provider: 'scripted-test', model: 'test/zero', latencyMs: 0 }, } satisfies Awaited>; @@ -521,6 +538,117 @@ describe('zero-swarm SimulationService tick', () => { expect(planner.observations).toHaveLength(2); }); + it('reports workers who wait on their targets as at-target to Zero', async () => { + const planner = new InspectingPlanner(false, 1); + const waitingReflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = observation.candidates.find(({ description }) => + description.startsWith('Remain on the current cell'), + )!; + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.id, + confidence: 1, + probabilities: Object.fromEntries( + observation.candidates.map(({ id }) => [ + id, + id === choice.id ? 1 : 0, + ]), + ), + model: 'test-reflex', + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + options?.beginAttempt?.('initial')?.({ + outcome: 'completed', + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = setup(planner, waitingReflex); + + await simulation.executeNextTick(); + await simulation.executeNextTick(); + await simulation.executeNextTick(); + + expect( + simulation + .getSnapshot() + .swarmTicks?.[1]?.workers.every( + ({ action, actionResult }) => + action?.type === 'wait' && actionResult?.accepted === true, + ), + ).toBe(true); + const workerObservations = planner.observations[1]?.agents.filter( + ({ agentId }) => agentId !== planner.observations[1]?.zeroAgentId, + ); + expect(workerObservations).toHaveLength(7); + expect(workerObservations?.map(({ workerStatus }) => workerStatus)).toEqual( + Array.from({ length: 7 }, () => 'at-target'), + ); + }); + + it('reports accepted moves toward a target as advancing to Zero', async () => { + const planner = new InspectingPlanner(false, 5, 2); + const advancingReflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = observation.candidates.find(({ description }) => + description.includes('This advances toward the assigned target.'), + )!; + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.id, + confidence: 1, + probabilities: Object.fromEntries( + observation.candidates.map(({ id }) => [ + id, + id === choice.id ? 1 : 0, + ]), + ), + replanProbability: 0.8, + model: 'test-reflex', + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + options?.beginAttempt?.('initial')?.({ + outcome: 'completed', + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = setup(planner, advancingReflex); + + await simulation.executeNextTick(); + await simulation.executeNextTick(); + + expect( + simulation + .getSnapshot() + .swarmTicks?.[0]?.workers.every( + ({ action, actionResult }) => + action?.type === 'move' && actionResult?.accepted === true, + ), + ).toBe(true); + const workerObservations = planner.observations[1]?.agents.filter( + ({ agentId }) => agentId !== planner.observations[1]?.zeroAgentId, + ); + expect(workerObservations?.map(({ workerStatus }) => workerStatus)).toEqual( + Array.from({ length: 7 }, () => 'advancing'), + ); + }); + it('releases reuse-tick reservations when a worker request is cancelled', async () => { let calls = 0; const reflex: ReflexProvider = { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 691706c..25b8010 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1916,7 +1916,7 @@ export class SimulationService { ]), ); this.#lastSwarmPositions = new Map( - [...state.agents.values()].map(({ id, currentCell }) => [ + [...candidate.agents.values()].map(({ id, currentCell }) => [ id, currentCell, ]), @@ -2768,14 +2768,27 @@ export class SimulationService { this.#lastValidSwarmPlan?.directives.find( ({ agentId }) => agentId === agent.id, ) ?? null; - const workerStatus = - priorWorker?.source === 'deterministic-fallback' - ? ('blocked' as const) - : priorWorker?.actionResult?.accepted - ? ('advancing' as const) - : priorWorker - ? ('stalled' as const) - : ('unknown' as const); + const previousCell = this.#lastSwarmPositions.get(agent.id); + let workerStatus: + 'advancing' | 'at-target' | 'stalled' | 'blocked' | 'unknown' = + 'unknown'; + if (priorWorker) { + if (directive?.targetCell === agent.currentCell) + workerStatus = 'at-target'; + else if ( + directive?.targetCell && + previousCell && + gridRingDistance(agent.currentCell, directive.targetCell) < + gridRingDistance(previousCell, directive.targetCell) + ) + workerStatus = 'advancing'; + else if ( + priorWorker.source === 'deterministic-fallback' || + priorWorker.actionResult?.accepted === false + ) + workerStatus = 'blocked'; + else workerStatus = 'stalled'; + } return { agentId: agent.id, position: agent.currentCell, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6839667..fe16630 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,6 +18,11 @@ The worker observation includes bounded, current capture alerts. The TypeSafe request projects them as structured capture pressure without cell IDs or tick numbers. The unused prose `relevantRecentFacts` field is removed; current situation and legal candidate descriptions supply the relevant local facts. +Directive progress compares each worker's current cell with its position before +the prior tick's physical action. Reaching the directive target has its own +`at-target` observation status. Zero's worker status uses the same position +comparison, rather than treating any accepted world action as progress. The +existing deterministic replanning trigger for repeated waits is unchanged. Zero planning uses the same provider-reported OpenRouter usage normalization as legacy turns, including actual `usage.cost` when returned, and preserves that metadata when a returned plan is rejected or a bounded non-success response diff --git a/docs/TESTING.md b/docs/TESTING.md index fc3512a..235f9c6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -38,6 +38,9 @@ The read-only `pnpm diagnose:swarm` command summarizes a running local Game API snapshot without provider calls, keys, prompts, or raw responses. World Lab's swarm activity summary shows pressure state, physical action counts, and fallbacks for immediate diagnosis of apparently stationary ticks. +Focused offline progress tests cover a worker advancing after a move, reaching +a directive target, waiting at that target, and the corresponding status seen +by Zero on the next planning tick. The pre-live provider audit tests the Jev request's bounded capture-pressure projection, omission of hidden hunter state and empty capture noise, and the diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cd05911..0209324 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -582,7 +582,12 @@ export const reflexObservationSchema = z currentSituation: z .object({ cellStatus: z.enum(['open', 'friendly-infected', 'other-infected']), - directiveProgress: z.enum(['advancing', 'stalled', 'blocked']), + directiveProgress: z.enum([ + 'advancing', + 'at-target', + 'stalled', + 'blocked', + ]), nearbyPressure: z.enum(['low', 'rising', 'high']), recentTerritoryTrend: z.enum(['growing', 'stable', 'shrinking']), recentActionOutcome: z.enum(['success', 'rejected', 'unknown']), @@ -704,7 +709,7 @@ export const zeroStrategicObservationSchema = z controlledCellCount: z.number().int().nonnegative(), territoryDelta: z.number().int(), workerStatus: z - .enum(['advancing', 'stalled', 'blocked', 'unknown']) + .enum(['advancing', 'at-target', 'stalled', 'blocked', 'unknown']) .optional(), directive: swarmDirectiveSchema.nullable().optional(), }) diff --git a/packages/shared/src/reflex.test.ts b/packages/shared/src/reflex.test.ts index d0bd279..a369d94 100644 --- a/packages/shared/src/reflex.test.ts +++ b/packages/shared/src/reflex.test.ts @@ -28,7 +28,7 @@ describe('zero-swarm reflex contracts', () => { directive, currentSituation: { cellStatus: 'open', - directiveProgress: 'advancing', + directiveProgress: 'at-target', nearbyPressure: 'low', recentTerritoryTrend: 'growing', recentActionOutcome: 'success',