diff --git a/.env.example b/.env.example index 41a21d4..538fb99 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,14 @@ +# Server-only OpenRouter configuration. Never use NEXT_PUBLIC_ for this key. +OPENROUTER_API_KEY=replace-with-your-openrouter-key +AGENTBORNE_MODEL=google/gemini-3.7-flash + +# The scripted provider is exclusively for deterministic automation. Normal +# interactive development must leave this unset so every turn uses OpenRouter. +# AGENTBORNE_PROVIDER=scripted + # Development-map center (Toledo, Ohio) and H3 resolution. NEXT_PUBLIC_DEV_MAP_LATITUDE=41.6528 NEXT_PUBLIC_DEV_MAP_LONGITUDE=-83.5379 NEXT_PUBLIC_DEV_MAP_H3_RESOLUTION=9 +# Optional browser API override. Normal development uses the Next.js proxy. +# NEXT_PUBLIC_GAME_API_BASE_URL=http://127.0.0.1:8787/api/simulation diff --git a/AGENTS.md b/AGENTS.md index ab300ab..4c3d880 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,5 +31,12 @@ These instructions apply throughout the repository. - Use Conventional Commit messages. Keep pull requests within one roadmap milestone. - Coding agents write and update appropriate tests, configure automatic GitHub CI, and provide the exact local validation commands for the repository owner to run. - The repository owner runs local formatting checks, lint, type checking, tests, builds, and Playwright. Coding agents must not run local validation unless the owner explicitly requests it in that session. +- After implementation and test authoring, coding agents must stop before pushing a branch or opening a pull request. +- The coding agent must provide the exact local validation commands and wait for the repository owner to run them. +- A branch may be pushed and a draft pull request may be opened only after the owner explicitly confirms in the same session that final local validation passed. +- If the owner reports a validation failure, fix it locally, provide the relevant commands, and pause again for owner retesting. +- Never infer validation approval from the original task prompt, completed implementation, local commits, or expected CI behavior. +- Automatic GitHub CI should begin only after the owner’s local validation gate has passed and the draft pull request is opened. +- Coding agents still must not run local validation unless explicitly authorized by the owner. - Automatic GitHub CI may run after a branch is pushed. Coding agents may inspect CI status and failure logs. - Coding agents must never claim local tests passed unless the repository owner supplied the results. diff --git a/README.md b/README.md index 9d1ed41..a417976 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,49 @@ # Agentborne -Agentborne is the working title for an agent-first GPS containment game. Persistent, named LLM agents will move across a real-world H3 grid, infect cells, communicate, form relationships, make plans, and react to players. The first product surface is the **World Lab**, a durable developer/admin interface for observing and experimenting with those agents. - -This repository is intentionally at the project-foundation milestone. It contains no real model calls or player systems yet. +Agentborne is the working title for an agent-first GPS containment game. The current milestone is the first visible LLM invasion: six persistent, named model-backed agents move and infect a real Toledo H3 world while the World Lab exposes every safe decision record. ## Workspace -| Path | Responsibility | -| ------------------------ | ------------------------------------------------------- | -| `apps/world-lab` | Next.js map and future simulation-observation interface | -| `apps/game-api` | Small Hono HTTP boundary for future clients | -| `packages/world-engine` | Pure world validation and consequence application | -| `packages/agent-runtime` | Model-provider boundary and scripted testing seam | -| `packages/shared` | Runtime-validated schemas and shared domain types | +| Path | Responsibility | +| ------------------------ | ---------------------------------------------------------------- | +| `apps/world-lab` | Next.js developer/admin map, controls, inspector, and event log | +| `apps/game-api` | Hono HTTP boundary and in-memory simulation service | +| `packages/world-engine` | Pure world validation and consequence application | +| `packages/agent-runtime` | OpenRouter provider boundary and explicit scripted testing seams | +| `packages/shared` | Runtime-validated schemas and inferred domain types | -## Requirements +## Local development -- Node.js 24.18.0 (see `.nvmrc` and `.node-version`) -- pnpm 11.21.0, pinned by the root `packageManager` field +Requirements are Node.js 24.18.0 and pnpm 11.21.0. Copy the example environment file to the repository-root `.env`, replace only the placeholder key, install dependencies, and start both applications: ```bash +cp .env.example .env +# Edit .env and set OPENROUTER_API_KEY. corepack enable pnpm install --frozen-lockfile pnpm dev ``` -The World Lab opens at . Run `pnpm dev:api` in another terminal for the API at ; its current endpoints are `GET /health` and `GET /api/development-world`. +Open the World Lab at . The Game API binds to ; Next.js narrowly proxies `/api/game/*` to it. `AGENTBORNE_MODEL` defaults to `google/gemini-3.7-flash`. + +Each Single turn or completed playback interval makes one third-party OpenRouter request and may incur cost. Start is deliberately disabled when the server has no key. This development API has no authentication or cost controls and is not suitable for an unauthenticated public deployment. + +State is held only in the Game API process. Restarting the API or pressing Reset reconstructs the same 61-cell Toledo world and six starting agents, with empty history. Model decisions remain nondeterministic. -Copy `.env.example` to `.env.local` only when changing the documented development-map center or H3 resolution. No secret is required. +## Opt-in real-provider smoke + +The smoke command performs exactly one bounded real decision request and validates it. It is never part of default tests or CI: + +```bash +pnpm smoke:openrouter +``` + +The command uses Node 24's built-in environment parser to read only `OPENROUTER_API_KEY` and `AGENTBORNE_MODEL` from the repository-root `.env`. Values in that file override stale exported values for the smoke process. ## Development map source -The default map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9. The shell uses MapLibre GL JS with the public OpenStreetMap raster tile endpoint and visible attribution. That tile endpoint is appropriate for light local development only; it has availability and usage-policy limits and is **not** the production basemap plan. A production deployment must select and configure a compliant tile provider or self-hosted tiles before public traffic. +The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a radius-four disk. MapLibre uses the public OpenStreetMap raster tile endpoint with attribution for light local development only. A public deployment must choose a compliant production tile source. + +Social messaging, relationship memory, player mechanics, persistence, autonomous scheduling, and provider configuration UI remain deferred to later roadmap milestones. See [Testing](docs/TESTING.md), [Architecture](docs/ARCHITECTURE.md), [Security](docs/SECURITY.md), and the [Roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index f6fd979..74022e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,8 @@ Produce the first video-worthy result: By the end of PR 2, a human must be able to watch real agents independently move around and infect the map. +Implementation scope: six fixed agent profiles, a 61-cell Toledo development world, server-owned in-memory round-robin turns, OpenRouter strict structured decisions, live World Lab controls/markers/inspector, and deterministic offline automation. Persistence, autonomous scheduling, messaging, and player systems remain explicitly out of scope. + ## PR 3 — Personality Lab Prompt/personality editing, presets, cloning, respawning, reproducible starting worlds, provider/model configuration, and cost visibility. diff --git a/apps/game-api/package.json b/apps/game-api/package.json index a228a3e..16d331f 100644 --- a/apps/game-api/package.json +++ b/apps/game-api/package.json @@ -12,9 +12,11 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@agentborne/agent-runtime": "workspace:*", "@agentborne/shared": "workspace:*", "@agentborne/world-engine": "workspace:*", "@hono/node-server": "2.1.0", + "h3-js": "4.5.0", "hono": "4.13.2" }, "devDependencies": { diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index a5f0ee4..c79b1b2 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -1,25 +1,216 @@ import { describe, expect, it } from 'vitest'; -import { worldSnapshotSchema } from '@agentborne/shared'; +import { + AgentProviderError, + OpenRouterAgentProvider, + ScriptedAgentProvider, + type AgentProvider, + type ProviderDecision, +} from '@agentborne/agent-runtime'; +import { + h3CellSchema, + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, +} from '@agentborne/shared'; import { createApp } from './app'; -describe('game API', () => { - const app = createApp(); - - it('reports health with a typed response', async () => { - const response = await app.request('/health'); +describe('game API simulation boundary', () => { + it('reports health and serves a schema-valid snapshot', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); + expect((await app.request('/health')).status).toBe(200); + const response = await app.request('/api/simulation'); expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ status: 'ok' }); + const payload = simulationSnapshotSchema.parse(await response.json()); + expect(payload.world.agents).toHaveLength(6); }); - it('serves a schema-valid development world', async () => { - const response = await app.request('/api/development-world'); - expect(response.status).toBe(200); - const payload = worldSnapshotSchema.parse(await response.json()); - expect(payload.hexes.length).toBeGreaterThan(6); - expect(payload.hexes.some((hex) => hex.state === 'infected')).toBe(true); + it('returns accepted and rejected single-turn records with valid response shapes', async () => { + const acceptedApp = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + }); + const accepted = singleTurnResponseSchema.parse( + await ( + await acceptedApp.request('/api/simulation/turn', { method: 'POST' }) + ).json(), + ); + expect(accepted.turn.outcome).toBe('accepted'); + + const rejectedApp = createApp({ + provider: new ScriptedAgentProvider([ + { + requestedAction: { + type: 'move', + targetCell: h3CellSchema.parse('8928308280fffff'), + }, + summary: 'Move far away.', + }, + ]), + }); + const rejected = singleTurnResponseSchema.parse( + await ( + await rejectedApp.request('/api/simulation/turn', { method: 'POST' }) + ).json(), + ); + expect(rejected.turn.outcome).toBe('rejected'); + }); + + it('returns provider failures and missing configuration safely', async () => { + const failureProvider: AgentProvider = { + mode: 'scripted-test', + model: 'failure-test', + configured: true, + async decide() { + throw new AgentProviderError( + { + code: 'network', + message: 'The model provider could not be reached.', + retryable: true, + }, + undefined, + { + httpStatus: 400, + providerMessage: 'internal-diagnostic-marker', + model: 'google/gemini-3.7-flash', + }, + ); + }, + }; + const failed = singleTurnResponseSchema.parse( + await ( + await createApp({ provider: failureProvider }).request( + '/api/simulation/turn', + { method: 'POST' }, + ) + ).json(), + ); + expect(failed.turn.outcome).toBe('provider-error'); + expect(JSON.stringify(failed)).not.toContain('internal-diagnostic-marker'); + + const missing = createApp({ provider: new OpenRouterAgentProvider() }); + const snapshot = simulationSnapshotSchema.parse( + await (await missing.request('/api/simulation')).json(), + ); + expect(snapshot).toMatchObject({ + status: 'configuration-error', + providerConfigured: false, + }); + }); + + it('returns internal errors for post-provider validation failures without advancing', async () => { + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'invalid-metadata-test', + configured: true, + async decide(): Promise { + calls += 1; + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: calls === 1 ? '' : 'invalid-metadata-test', + latencyMs: 0, + }, + } as ProviderDecision; + }, + }; + const app = createApp({ provider }); + const initial = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + + const failed = await app.request('/api/simulation/turn', { + method: 'POST', + }); + expect(failed.status).toBe(500); + await expect(failed.json()).resolves.toEqual({ + error: { + code: 'internal_error', + message: 'An unexpected error occurred.', + }, + }); + + const afterFailure = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + expect(afterFailure).toMatchObject({ + turnNumber: 0, + turns: [], + nextAgentId: initial.nextAgentId, + activeAgentId: null, + status: 'paused', + }); + expect(afterFailure.world).toEqual(initial.world); + + const recovered = singleTurnResponseSchema.parse( + await ( + await app.request('/api/simulation/turn', { method: 'POST' }) + ).json(), + ); + expect(recovered.turn).toMatchObject({ + turnNumber: 1, + agentId: initial.nextAgentId, + outcome: 'accepted', + }); + }); + + it('resets the complete simulation', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + }); + await app.request('/api/simulation/turn', { method: 'POST' }); + const response = await app.request('/api/simulation/reset', { + method: 'POST', + }); + const reset = resetSimulationResponseSchema.parse(await response.json()); + expect(reset.snapshot.turnNumber).toBe(0); + expect(reset.snapshot.world.events).toEqual([]); + }); + + it('returns typed conflicts for an overlapping turn and reset', async () => { + let release!: (result: ProviderDecision) => void; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'deferred-test', + configured: true, + decide: () => + new Promise((resolve) => { + release = resolve; + }), + }; + const app = createApp({ provider }); + const pending = app.request('/api/simulation/turn', { method: 'POST' }); + expect( + (await app.request('/api/simulation/turn', { method: 'POST' })).status, + ).toBe(409); + expect( + (await app.request('/api/simulation/reset', { method: 'POST' })).status, + ).toBe(409); + release({ + decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, + metadata: { + provider: 'scripted-test', + model: 'deferred-test', + latencyMs: 0, + }, + }); + expect((await pending).status).toBe(200); }); it('uses a predictable error envelope', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); const response = await app.request('/missing'); expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index b2f6fc9..7ff8d0c 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -1,6 +1,23 @@ import { Hono } from 'hono'; -import { apiErrorSchema, worldSnapshotSchema } from '@agentborne/shared'; +import { cors } from 'hono/cors'; +import { + BrowserTestAgentProvider, + DEFAULT_OPENROUTER_MODEL, + OpenRouterAgentProvider, + type AgentProvider, +} from '@agentborne/agent-runtime'; +import { + apiErrorSchema, + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, + worldSnapshotSchema, +} from '@agentborne/shared'; import { createDevelopmentWorld } from '@agentborne/world-engine'; +import { + SimulationConflictError, + SimulationService, +} from './simulation-service'; export const healthResponseSchema = worldSnapshotSchema .pick({ generatedAt: true }) @@ -9,19 +26,93 @@ export const healthResponseSchema = worldSnapshotSchema checkedAt: generatedAt, })); -export function createApp() { - const app = new Hono(); +export interface AppOptions { + service?: SimulationService; + provider?: AgentProvider; +} + +export function providerFromEnvironment(): AgentProvider { + if (process.env.AGENTBORNE_PROVIDER === 'scripted') { + return new BrowserTestAgentProvider(); + } + return new OpenRouterAgentProvider({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.AGENTBORNE_MODEL ?? DEFAULT_OPENROUTER_MODEL, + }); +} - app.get('/health', (context) => { - const payload = healthResponseSchema.parse({ - generatedAt: new Date().toISOString(), +export function createApp(options: AppOptions = {}) { + const app = new Hono(); + const service = + options.service ?? + new SimulationService({ + provider: options.provider ?? providerFromEnvironment(), }); - return context.json(payload); + + app.use( + '/api/*', + cors({ + origin: (origin) => + ['http://localhost:3000', 'http://127.0.0.1:3000'].includes(origin) + ? origin + : null, + allowMethods: ['GET', 'POST'], + allowHeaders: ['Content-Type'], + }), + ); + + app.get('/health', (context) => + context.json( + healthResponseSchema.parse({ generatedAt: new Date().toISOString() }), + ), + ); + + app.get('/api/development-world', (context) => + context.json(worldSnapshotSchema.parse(createDevelopmentWorld())), + ); + + app.get('/api/simulation', (context) => + context.json(simulationSnapshotSchema.parse(service.getSnapshot())), + ); + + app.post('/api/simulation/turn', async (context) => { + try { + const turn = await service.executeNextTurn(); + return context.json( + singleTurnResponseSchema.parse({ + snapshot: service.getSnapshot(), + turn, + }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'turn_conflict', message: error.message }, + }), + 409, + ); + } + throw error; + } }); - app.get('/api/development-world', (context) => { - const payload = worldSnapshotSchema.parse(createDevelopmentWorld()); - return context.json(payload); + app.post('/api/simulation/reset', (context) => { + try { + return context.json( + resetSimulationResponseSchema.parse({ snapshot: service.reset() }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'reset_conflict', message: error.message }, + }), + 409, + ); + } + throw error; + } }); app.notFound((context) => @@ -37,7 +128,10 @@ export function createApp() { ); app.onError((error, context) => { - console.error('Unhandled API error', error); + console.error( + 'Unhandled API error', + error instanceof Error ? error.name : 'unknown', + ); return context.json( apiErrorSchema.parse({ error: { diff --git a/apps/game-api/src/server.ts b/apps/game-api/src/server.ts index e2077f2..a76e8d7 100644 --- a/apps/game-api/src/server.ts +++ b/apps/game-api/src/server.ts @@ -1,8 +1,28 @@ +import { readFileSync } from 'node:fs'; import { serve } from '@hono/node-server'; +import { applyProviderEnvironmentFile } from '@agentborne/agent-runtime'; import { createApp } from './app'; +try { + process.loadEnvFile('../../.env'); + applyProviderEnvironmentFile( + readFileSync(new URL('../../../.env', import.meta.url), 'utf8'), + ); +} catch (error) { + const code = + error instanceof Error && 'code' in error ? error.code : undefined; + if (code !== 'ENOENT') throw error; +} + const port = Number.parseInt(process.env.PORT ?? '8787', 10); -serve({ fetch: createApp().fetch, port }, ({ port: listeningPort }) => { - console.log(`Game API listening on http://localhost:${listeningPort}`); -}); +serve( + { + fetch: createApp().fetch, + hostname: '127.0.0.1', + port, + }, + ({ port: listeningPort }) => { + console.log(`Game API listening on http://localhost:${listeningPort}`); + }, +); diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts new file mode 100644 index 0000000..1c03839 --- /dev/null +++ b/apps/game-api/src/simulation-service.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from 'vitest'; +import { + AgentProviderError, + ScriptedAgentProvider, + type AgentProvider, + type ProviderDecision, +} from '@agentborne/agent-runtime'; +import { + h3CellSchema, + type AgentObservation, + type AgentTurnRecord, + type WorldEvent, +} from '@agentborne/shared'; +import { + SimulationConflictError, + SimulationService, +} from './simulation-service'; + +const now = () => '2026-08-13T12:00:01.000Z'; +const createEventId = () => '67aa21b9-fc78-4b04-9f92-9862bf346f96'; + +function service(provider: AgentProvider) { + return new SimulationService({ provider, now, createEventId }); +} + +describe('SimulationService', () => { + it('resets to the exact deterministic six-agent starting world', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + const initial = simulation.getSnapshot(); + await simulation.executeNextTurn(); + expect(simulation.reset()).toEqual(initial); + expect(initial.world.agents).toHaveLength(6); + expect(initial.world.hexes).toHaveLength(61); + }); + + it('calls the provider exactly once per turn in round-robin order', async () => { + const seen: AgentObservation[] = []; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'recording-test', + configured: true, + async decide(observation): Promise { + seen.push(observation); + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'recording-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const order = simulation.getSnapshot().world.agents.map(({ id }) => id); + for (let index = 0; index < 7; index += 1) + await simulation.executeNextTurn(); + expect(seen).toHaveLength(7); + expect(seen.map(({ agentId }) => agentId)).toEqual([...order, order[0]]); + }); + + it('keeps total turn numbering and round robin independent of retained history', async () => { + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'long-running-test', + configured: true, + async decide(): Promise { + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'long-running-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const agentOrder = simulation + .getSnapshot() + .world.agents.map(({ id }) => id); + + for (let index = 0; index < 125; index += 1) { + await simulation.executeNextTurn(); + } + + const snapshot = simulation.getSnapshot(); + const retainedNumbers = snapshot.turns.map(({ turnNumber }) => turnNumber); + expect(snapshot.turnNumber).toBe(125); + expect(snapshot.turns).toHaveLength(120); + expect(retainedNumbers).toEqual( + Array.from({ length: 120 }, (_, index) => index + 6), + ); + expect(new Set(retainedNumbers).size).toBe(120); + expect(snapshot.turns.map(({ agentId }) => agentId)).toEqual( + snapshot.turns.map( + ({ turnNumber }) => agentOrder[(turnNumber - 1) % agentOrder.length], + ), + ); + expect(snapshot.nextAgentId).toBe(agentOrder[125 % agentOrder.length]); + }); + + it('builds each observation from the latest authoritative world state', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + { requestedAction: { type: 'wait' }, summary: 'Observe.' }, + ]), + ); + await simulation.executeNextTurn(); + const second = await simulation.executeNextTurn(); + expect(second.observation.recentEvents).toHaveLength(1); + expect(second.observation.recentEvents[0]?.type).toBe('hex-infected'); + }); + + it('records accepted and rejected actions without mutating on rejection', async () => { + const initial = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + expect((await initial.executeNextTurn()).outcome).toBe('accepted'); + + const rejected = service( + new ScriptedAgentProvider([ + { + requestedAction: { + type: 'move', + targetCell: h3CellSchema.parse('8928308280fffff'), + }, + summary: 'Attempt a distant move.', + }, + { requestedAction: { type: 'wait' }, summary: 'Continue.' }, + ]), + ); + const before = rejected.getSnapshot().world; + expect(await rejected.executeNextTurn()).toMatchObject({ + turnNumber: 1, + outcome: 'rejected', + }); + expect(rejected.getSnapshot().world.hexes).toEqual(before.hexes); + expect(rejected.getSnapshot().world.agents).toEqual(before.agents); + expect(await rejected.executeNextTurn()).toMatchObject({ + turnNumber: 2, + outcome: 'accepted', + }); + expect(rejected.getSnapshot().turnNumber).toBe(2); + }); + + it('records a sanitized provider failure, preserves the world, and continues', async () => { + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'failure-test', + configured: true, + async decide(): Promise { + calls += 1; + if (calls === 1) + throw new AgentProviderError({ + code: 'timeout', + message: 'The model request timed out.', + retryable: true, + }); + return { + decision: { + requestedAction: { type: 'wait' }, + summary: 'Recovered.', + }, + metadata: { + provider: 'scripted-test', + model: 'failure-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const before = simulation.getSnapshot().world; + expect(await simulation.executeNextTurn()).toMatchObject({ + turnNumber: 1, + outcome: 'provider-error', + }); + expect(simulation.getSnapshot().world).toEqual(before); + expect(await simulation.executeNextTurn()).toMatchObject({ + turnNumber: 2, + outcome: 'accepted', + }); + expect(simulation.getSnapshot().turnNumber).toBe(2); + }); + + it('does not commit or advance after post-provider validation fails', async () => { + const seenAgentIds: AgentObservation['agentId'][] = []; + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'invalid-metadata-test', + configured: true, + async decide(observation): Promise { + calls += 1; + seenAgentIds.push(observation.agentId); + if (calls === 1) { + return { + decision: { + requestedAction: { type: 'wait' }, + summary: 'Invalid metadata follows.', + }, + metadata: { + provider: 'scripted-test', + model: '', + latencyMs: 0, + }, + } as ProviderDecision; + } + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Valid.' }, + metadata: { + provider: 'scripted-test', + model: 'invalid-metadata-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const before = simulation.getSnapshot(); + + await expect(simulation.executeNextTurn()).rejects.toBeDefined(); + + const afterFailure = simulation.getSnapshot(); + expect(afterFailure.world).toEqual(before.world); + expect(afterFailure.turnNumber).toBe(0); + expect(afterFailure.turns).toEqual([]); + expect(afterFailure.nextAgentId).toBe(before.nextAgentId); + expect(afterFailure).toMatchObject({ + activeAgentId: null, + status: 'paused', + }); + + const recovered = await simulation.executeNextTurn(); + expect(recovered).toMatchObject({ + turnNumber: 1, + agentId: before.nextAgentId, + outcome: 'accepted', + }); + expect(seenAgentIds).toEqual([before.nextAgentId, before.nextAgentId]); + }); + + it('retains only the newest world events without changing current state', async () => { + let clock = 0; + let eventSequence = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'moving-history-test', + configured: true, + async decide(observation): Promise { + return { + decision: { + requestedAction: { + type: 'move', + targetCell: observation.adjacentCells[0]!.cell, + }, + summary: 'Move.', + }, + metadata: { + provider: 'scripted-test', + model: 'moving-history-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = new SimulationService({ + provider, + now: () => + new Date( + Date.parse('2026-08-13T12:00:00.000Z') + clock++, + ).toISOString(), + createEventId: () => + `67aa21b9-fc78-4b04-9f92-${String(++eventSequence).padStart(12, '0')}`, + }); + const producedEvents: WorldEvent[] = []; + let lastRecord: AgentTurnRecord | undefined; + + for (let index = 0; index < 125; index += 1) { + lastRecord = await simulation.executeNextTurn(); + if (lastRecord.outcome !== 'accepted') { + throw new Error( + 'The moving history fixture must produce accepted turns.', + ); + } + producedEvents.push(lastRecord.event); + } + + const snapshot = simulation.getSnapshot(); + expect(snapshot.world.events).toHaveLength(120); + expect(snapshot.world.events).toEqual(producedEvents.slice(-120)); + expect( + lastRecord?.observation.recentEvents.map(({ occurredAt }) => occurredAt), + ).toEqual(producedEvents.slice(-9, -1).map(({ occurredAt }) => occurredAt)); + + for (const agent of snapshot.world.agents) { + const latestMove = producedEvents + .filter( + (event) => event.type === 'agent-moved' && event.agentId === agent.id, + ) + .at(-1); + expect(latestMove?.type).toBe('agent-moved'); + if (latestMove?.type === 'agent-moved') { + expect(agent.currentCell).toBe(latestMove.toCell); + } + } + }); + + it('prevents overlapping turns and reset during an in-flight request', async () => { + let release!: (result: ProviderDecision) => void; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'deferred-test', + configured: true, + decide: () => + new Promise((resolve) => { + release = resolve; + }), + }; + const simulation = service(provider); + const pending = simulation.executeNextTurn(); + await expect(simulation.executeNextTurn()).rejects.toBeInstanceOf( + SimulationConflictError, + ); + expect(() => simulation.reset()).toThrow(SimulationConflictError); + release({ + decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, + metadata: { + provider: 'scripted-test', + model: 'deferred-test', + latencyMs: 0, + }, + }); + await pending; + expect(simulation.getSnapshot().turnNumber).toBe(1); + }); +}); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts new file mode 100644 index 0000000..4472d78 --- /dev/null +++ b/apps/game-api/src/simulation-service.ts @@ -0,0 +1,301 @@ +import { gridDistance, gridDisk } from 'h3-js'; +import { + AgentProviderError, + type AgentProvider, + type ProviderDecision, +} from '@agentborne/agent-runtime'; +import { + agentObservationSchema, + agentTurnRecordSchema, + h3CellSchema, + simulationSnapshotSchema, + type AgentId, + type AgentObservation, + type AgentTurnRecord, + type H3Cell, + type ProviderFailure, + type SimulationSnapshot, + type SimulationStatus, + type WorldEvent, +} from '@agentborne/shared'; +import { + applyRequestedAction, + createDevelopmentWorld, + toWorldState, + type WorldState, +} from '@agentborne/world-engine'; + +const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; +const MAX_TURN_HISTORY = 120; +const MAX_WORLD_EVENT_HISTORY = 120; + +export class SimulationConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'SimulationConflictError'; + } +} + +export interface SimulationServiceOptions { + provider: AgentProvider; + now?: () => string; + createEventId?: () => string; +} + +export class SimulationService { + readonly #provider: AgentProvider; + readonly #now: () => string; + readonly #createEventId: () => string; + #state: WorldState; + #turns: AgentTurnRecord[] = []; + #completedTurnCount = 0; + #cursor = 0; + #busy = false; + #status: SimulationStatus; + #activeAgentId: AgentId | null = null; + + constructor({ + provider, + now = () => new Date().toISOString(), + createEventId = () => crypto.randomUUID(), + }: SimulationServiceOptions) { + this.#provider = provider; + this.#now = now; + this.#createEventId = createEventId; + this.#state = toWorldState( + createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), + ); + this.#status = provider.configured ? 'paused' : 'configuration-error'; + } + + getSnapshot(): SimulationSnapshot { + const agents = [...this.#state.agents.values()]; + const next = agents[this.#cursor % agents.length]; + if (!next) throw new Error('The development world has no agents.'); + return simulationSnapshotSchema.parse({ + world: { + generatedAt: RESET_GENERATED_AT, + hexes: [...this.#state.hexes].map(([cell, state]) => ({ cell, state })), + agents, + events: this.#state.events, + }, + turnNumber: this.#completedTurnCount, + nextAgentId: next.id, + activeAgentId: this.#activeAgentId, + status: this.#status, + providerMode: this.#provider.mode, + providerConfigured: this.#provider.configured, + turns: this.#turns, + }); + } + + reset(): SimulationSnapshot { + if (this.#busy) { + throw new SimulationConflictError( + 'Reset is unavailable while a model turn is in progress.', + ); + } + this.#status = 'resetting'; + this.#state = toWorldState( + createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), + ); + this.#turns = []; + this.#completedTurnCount = 0; + this.#cursor = 0; + this.#activeAgentId = null; + this.#status = this.#provider.configured ? 'paused' : 'configuration-error'; + return this.getSnapshot(); + } + + async executeNextTurn(): Promise { + if (this.#busy) { + throw new SimulationConflictError('A model turn is already in progress.'); + } + const agents = [...this.#state.agents.values()]; + const agent = agents[this.#cursor % agents.length]; + if (!agent) throw new Error('The development world has no agents.'); + + this.#busy = true; + this.#activeAgentId = agent.id; + this.#status = 'waiting-for-model'; + + try { + const startedAt = this.#now(); + const observation = this.#buildObservation(agent.id); + const turnNumber = this.#completedTurnCount + 1; + const providerObservation = structuredClone(observation); + let providerResult: ProviderDecision; + + try { + providerResult = await this.#provider.decide(providerObservation); + } catch (error) { + const providerError = asProviderError(error); + const record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'provider-error', + failure: providerError.failure, + provider: providerError.metadata, + }); + this.#commitCompletedTurn(record, this.#state, agents.length); + this.#status = + providerError.failure.code === 'configuration' + ? 'configuration-error' + : 'provider-error'; + return record; + } + + const applied = applyRequestedAction( + this.#state, + agent.id, + providerResult.decision.requestedAction, + { now: this.#now, createEventId: this.#createEventId }, + ); + let record: AgentTurnRecord; + let candidateState = this.#state; + + if (applied.result.accepted) { + record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'accepted', + requestedAction: providerResult.decision.requestedAction, + summary: providerResult.decision.summary, + validation: { accepted: true }, + event: applied.result.event, + provider: providerResult.metadata, + }); + candidateState = { + ...applied.state, + events: applied.state.events.slice(-MAX_WORLD_EVENT_HISTORY), + }; + } else { + record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'rejected', + requestedAction: providerResult.decision.requestedAction, + summary: providerResult.decision.summary, + validation: applied.result, + provider: providerResult.metadata, + }); + } + + this.#commitCompletedTurn(record, candidateState, agents.length); + this.#status = 'paused'; + return record; + } finally { + this.#busy = false; + this.#activeAgentId = null; + if (this.#status === 'waiting-for-model') { + this.#status = this.#provider.configured + ? 'paused' + : 'configuration-error'; + } + } + } + + #commitCompletedTurn( + record: AgentTurnRecord, + state: WorldState, + agentCount: number, + ): void { + const turns = [...this.#turns, record].slice(-MAX_TURN_HISTORY); + const cursor = (this.#cursor + 1) % agentCount; + + this.#state = state; + this.#turns = turns; + this.#completedTurnCount = record.turnNumber; + this.#cursor = cursor; + } + + #buildObservation(agentId: AgentId): AgentObservation { + const agent = this.#state.agents.get(agentId); + if (!agent) throw new Error('The active agent does not exist.'); + const stateFor = (cell: H3Cell) => { + const state = this.#state.hexes.get(cell); + if (!state) throw new Error('Observation cell is outside the world.'); + return { cell, state }; + }; + const adjacentCells = gridDisk(agent.currentCell, 1) + .filter((cell) => cell !== agent.currentCell) + .map((cell) => h3CellSchema.parse(cell)) + .filter((cell) => this.#state.hexes.has(cell)) + .map(stateFor); + const nearbyAgents = [...this.#state.agents.values()] + .filter((candidate) => candidate.id !== agent.id) + .map((candidate) => ({ + id: candidate.id, + name: candidate.name, + currentCell: candidate.currentCell, + distance: safeDistance(agent.currentCell, candidate.currentCell), + })) + .filter(({ distance }) => distance <= 4) + .sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)) + .slice(0, 5); + const recentEvents = this.#state.events + .filter( + (event): event is Exclude => + event.type !== 'agent-messaged', + ) + .slice(-8) + .map((event) => ({ + type: event.type, + agentId: event.agentId, + occurredAt: event.occurredAt, + summary: summarizeEvent(event, this.#state), + })); + return agentObservationSchema.parse({ + agentId: agent.id, + agentName: agent.name, + personality: agent.personality, + currentCell: stateFor(agent.currentCell), + adjacentCells, + nearbyAgents, + recentEvents, + }); + } +} + +function safeDistance(from: H3Cell, to: H3Cell): number { + try { + return gridDistance(from, to); + } catch { + return 99; + } +} + +function summarizeEvent( + event: Exclude, + state: WorldState, +): string { + const name = state.agents.get(event.agentId)?.name ?? 'An agent'; + if (event.type === 'agent-moved') return `${name} moved to ${event.toCell}.`; + if (event.type === 'hex-infected') return `${name} infected ${event.cell}.`; + return `${name} waited.`; +} + +function asProviderError(error: unknown): { + failure: ProviderFailure; + metadata?: AgentProviderError['metadata']; +} { + if (error instanceof AgentProviderError) { + return { failure: error.failure, metadata: error.metadata }; + } + return { + failure: { + code: 'network', + message: 'The model provider failed unexpectedly.', + retryable: true, + }, + }; +} diff --git a/apps/world-lab/next.config.ts b/apps/world-lab/next.config.ts index c09960a..303a595 100644 --- a/apps/world-lab/next.config.ts +++ b/apps/world-lab/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + allowedDevOrigins: ['127.0.0.1'], transpilePackages: ['@agentborne/shared', '@agentborne/world-engine'], + async rewrites() { + return [ + { + source: '/api/game/:path*', + destination: 'http://127.0.0.1:8787/api/:path*', + }, + ]; + }, }; export default nextConfig; diff --git a/apps/world-lab/src/app/maplibre-worker/[file]/route.ts b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts new file mode 100644 index 0000000..8f121ea --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { notFound } from 'next/navigation'; + +const mapLibreWorkerFiles = new Set([ + 'maplibre-gl-shared.mjs', + 'maplibre-gl-worker.mjs', +]); + +export async function GET( + _request: Request, + context: { params: Promise<{ file: string }> }, +) { + const { file } = await context.params; + if (!mapLibreWorkerFiles.has(file)) notFound(); + + const contents = await readFile( + join(process.cwd(), 'node_modules', 'maplibre-gl', 'dist', file), + ); + + return new Response(contents, { + headers: { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Type': 'text/javascript; charset=utf-8', + }, + }); +} diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 70882ac..54c1eec 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -21,7 +21,8 @@ body { background: #111716; } button, -input { +input, +select { font: inherit; } @@ -70,6 +71,35 @@ h2 { background: #d3a64d; box-shadow: 0 0 0 4px #d3a64d1c; } +.status-group { + display: grid; + justify-items: end; + gap: 7px; +} +.status.running span { + background: #69d59b; +} +.status.waiting-for-model span, +.status.resetting span { + background: #63d2ff; + animation: pulse 1s ease-in-out infinite alternate; +} +.status.configuration-error span, +.status.provider-error span { + background: #e44f45; +} +.provider-badge { + padding: 4px 8px; + border: 1px solid #364540; + border-radius: 999px; + color: #9fb1ab; + font-size: 0.68rem; +} +@keyframes pulse { + to { + opacity: 0.35; + } +} .workspace { display: grid; @@ -81,11 +111,49 @@ h2 { min-height: 670px; border-right: 1px solid #2d3835; } -.world-map { +.map-stage { position: absolute; inset: 0 0 42px; +} +.world-map { + position: absolute; + inset: 0; background: #1a2321; } +.map-ready { + position: absolute; + z-index: 2; + left: 12px; + top: 12px; + padding: 6px 9px; + border: 1px solid #41504b; + border-radius: 5px; + background: #111716dd; + color: #bed0ca; + font-size: 0.7rem; + pointer-events: none; +} +.agent-marker { + width: 30px; + height: 30px; + padding: 0; + border: 3px solid #101615; + border-radius: 50%; + background: var(--agent-color); + color: #101615; + font-weight: 850; + line-height: 24px; + cursor: pointer; + box-shadow: 0 2px 8px #000b; +} +.agent-marker:hover, +.agent-marker.selected { + border-color: #fff5d8; + box-shadow: + 0 0 0 4px #fff5d855, + 0 2px 8px #000b; + transform: scale(1.12); +} .map-caption { position: absolute; inset: auto 0 0; @@ -103,6 +171,8 @@ h2 { .sidebar { background: #151c1a; + max-height: calc(100vh - 88px); + overflow-y: auto; } .panel { padding: 22px; @@ -168,18 +238,11 @@ dd { border-color: #fff2d3; box-shadow: 0 0 0 2px #fff2d344; } -.reserved-panel { - color: #8fa19b; -} -.reserved-panel h2, +.controls-panel h2, .event-panel h2 { color: #e6ece8; margin-bottom: 11px; } -.reserved-panel > p:last-child { - font-size: 0.8rem; - line-height: 1.5; -} .control-row { display: flex; gap: 7px; @@ -189,8 +252,109 @@ dd { padding: 7px 10px; border: 1px solid #394642; border-radius: 5px; + color: #dbe7e3; + background: #202927; + cursor: pointer; +} +.control-row button:hover:not(:disabled) { + border-color: #739087; + background: #2a3633; +} +.control-row button:disabled { color: #71807b; + cursor: not-allowed; +} +.next-agent { + color: #9aaba5; + font-size: 0.8rem; +} +.speed-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #9aaba5; + font-size: 0.78rem; +} +.speed-control select { + padding: 5px 8px; + border: 1px solid #394642; + border-radius: 5px; background: #202927; + color: #dbe7e3; +} +.callout { + margin-top: 12px; + padding: 9px; + border-radius: 5px; + font-size: 0.76rem; + line-height: 1.45; +} +.callout.configuration, +.callout.error { + border: 1px solid #78443d; + background: #3c2421; + color: #ffc2b8; +} +.agent-inspector h2 { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 15px; + font-size: 1.25rem; +} +.agent-inspector h3 { + margin: 17px 0 7px; + color: #9fb1ab; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.agent-inspector > p, +.turn-detail p, +.turn-detail details, +.compact-history { + color: #bcc9c5; + font-size: 0.77rem; + line-height: 1.5; +} +.agent-swatch { + width: 12px; + height: 12px; + border: 2px solid #e6ece8; + border-radius: 50%; +} +.outcome { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + font-size: 0.67rem !important; + font-weight: 750; +} +.outcome.accepted { + background: #214b39; + color: #8ff1ba; +} +.outcome.rejected, +.outcome.provider-error { + background: #512b27; + color: #ffafa3; +} +.provider-meta, +.muted { + color: #81928c !important; +} +.turn-detail details { + margin-top: 10px; +} +.turn-detail details p { + margin-top: 6px; + overflow-wrap: anywhere; +} +.compact-history { + margin: 0; + padding-left: 18px; } .event-panel ol { list-style: none; @@ -202,10 +366,22 @@ dd { grid-template-columns: 55px 1fr; gap: 10px; font-size: 0.8rem; + padding: 4px 0; } .event-panel time { color: #82938e; } +.event-panel li[data-outcome='provider-error'], +.event-panel li[data-outcome='rejected'] { + color: #ffafa3; +} +.loading-state { + display: grid; + min-height: 100vh; + place-content: center; + gap: 10px; + text-align: center; +} @media (max-width: 780px) { .topbar { @@ -228,6 +404,12 @@ dd { height: 56px; } .world-map { + bottom: 0; + } + .map-stage { bottom: 56px; } + .sidebar { + max-height: none; + } } diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index f21e93c..09f7b87 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1,57 +1,500 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { gridDisk } from 'h3-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + simulationSnapshotSchema, + type SimulationSnapshot, +} from '@agentborne/shared'; +import { createDevelopmentWorld } from '@agentborne/world-engine'; import { WorldLab } from './world-lab'; +const mapLibreMock = vi.hoisted(() => ({ + renderMode: 'complete' as 'complete' | 'incomplete', + rejectSource: false, + rejectLayers: false, + duplicateFeatures: false, + autoRender: true, + pendingRenderCallbacks: [] as Array<() => void>, + mapClick: undefined as + | ((event: { features: Array<{ properties: { cell: string } }> }) => void) + | undefined, + layers: [] as Array<{ + id: string; + paint?: Record; + }>, + queryRenderedFeatures: vi.fn(), + setData: vi.fn(), +})); + vi.mock('maplibre-gl', () => { class Map { + source: + | { + data: { + features: Array<{ + properties: { cell: string; state: string; selected: boolean }; + }>; + }; + setData: (data: unknown) => void; + } + | undefined; + sourceLoaded = false; + layers = new Set(); + listeners = new globalThis.Map void>>(); addControl() {} - addLayer() {} - addSource() {} + addLayer(layer: { id: string; paint?: Record }) { + mapLibreMock.layers.push(layer); + if (!mapLibreMock.rejectLayers) this.layers.add(layer.id); + } + addSource( + id: string, + source: { + data: { + features: Array<{ + properties: { cell: string; state: string; selected: boolean }; + }>; + }; + }, + ) { + if (mapLibreMock.rejectSource) return; + const completeSourceUpdate = () => { + this.sourceLoaded = true; + this.emit('sourcedata', { sourceId: id, isSourceLoaded: true }); + }; + this.source = { + data: source.data, + setData: (data) => { + mapLibreMock.setData(data); + this.source!.data = data as typeof source.data; + this.sourceLoaded = false; + queueMicrotask(completeSourceUpdate); + }, + }; + queueMicrotask(completeSourceUpdate); + } + emit(event: string, eventData?: unknown) { + for (const listener of [...(this.listeners.get(event) ?? [])]) { + listener(eventData); + } + } + fitBounds() {} getCanvas() { return { style: { cursor: '' } }; } + getLayer(id: string) { + return this.layers.has(id) ? { id } : undefined; + } getSource() { - return undefined; + return this.source; + } + isSourceLoaded() { + return Boolean(this.source) && this.sourceLoaded; + } + queryRenderedFeatures(options: { layers: string[] }) { + mapLibreMock.queryRenderedFeatures(options); + if (!this.source || !this.layers.has('development-hex-fills')) return []; + const features = + mapLibreMock.renderMode === 'incomplete' + ? this.source.data.features.slice(0, -1) + : [...this.source.data.features]; + return mapLibreMock.duplicateFeatures && features[0] + ? [...features, features[0], features[0]] + : features; + } + on( + event: string, + layerOrCallback: unknown, + callback?: (event: { + features: Array<{ properties: { cell: string } }>; + }) => void, + ) { + if (event === 'load' && typeof layerOrCallback === 'function') { + queueMicrotask(() => layerOrCallback()); + } + if (event === 'click' && typeof callback === 'function') { + mapLibreMock.mapClick = callback; + } else if (event !== 'load' && typeof layerOrCallback === 'function') { + const listeners = + this.listeners.get(event) ?? new Set<(event?: unknown) => void>(); + listeners.add(layerOrCallback as (event?: unknown) => void); + this.listeners.set(event, listeners); + } } - on(event: string, layerOrCallback: unknown, callback?: () => void) { - if (event === 'load' && typeof layerOrCallback === 'function') - layerOrCallback(); - void callback; + off(event: string, layerOrCallback: unknown, callback?: () => void) { + if (typeof layerOrCallback === 'function') { + this.listeners.get(event)?.delete(layerOrCallback as () => void); + } + if (event === 'click' && callback) mapLibreMock.mapClick = undefined; + } + triggerRepaint() { + const completeRender = () => this.emit('render'); + if (mapLibreMock.autoRender) queueMicrotask(completeRender); + else mapLibreMock.pendingRenderCallbacks.push(completeRender); } remove() {} } + class Marker { + constructor(private options: { element: HTMLElement }) {} + setLngLat() { + return this; + } + addTo() { + document.body.append(this.options.element); + return this; + } + remove() { + this.options.element.remove(); + } + } return { + setWorkerUrl: vi.fn(), Map, + Marker, + LngLatBounds: class { + extend() { + return this; + } + }, NavigationControl: class {}, AttributionControl: class {}, }; }); +const world = createDevelopmentWorld({ + generatedAt: '2026-08-13T12:00:00.000Z', +}); +const initial = simulationSnapshotSchema.parse({ + world, + turnNumber: 0, + nextAgentId: world.agents[0]!.id, + activeAgentId: null, + status: 'paused', + providerMode: 'scripted-test', + providerConfigured: true, + turns: [], +}); + +function afterInfection(): SimulationSnapshot { + const agent = world.agents[0]!; + const event = { + id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + agentId: agent.id, + occurredAt: '2026-08-13T12:00:01.000Z', + type: 'hex-infected' as const, + cell: agent.currentCell, + }; + const adjacent = gridDisk(agent.currentCell, 1).find( + (cell) => + cell !== agent.currentCell && + world.hexes.some((hex) => hex.cell === cell), + )!; + const turn = { + turnNumber: 1, + agentId: agent.id, + startedAt: '2026-08-13T12:00:00.000Z', + completedAt: '2026-08-13T12:00:01.000Z', + observation: { + agentId: agent.id, + agentName: agent.name, + personality: agent.personality, + currentCell: { cell: agent.currentCell, state: 'open' as const }, + adjacentCells: [{ cell: adjacent, state: 'open' as const }], + nearbyAgents: [], + recentEvents: [], + }, + outcome: 'accepted' as const, + requestedAction: { type: 'infect' as const }, + summary: 'Infecting this open cell.', + validation: { accepted: true as const }, + event, + provider: { + provider: 'scripted-test' as const, + model: 'test', + latencyMs: 0, + }, + }; + return simulationSnapshotSchema.parse({ + ...initial, + world: { + ...world, + hexes: world.hexes.map((hex) => + hex.cell === agent.currentCell + ? { ...hex, state: 'infected' as const } + : hex, + ), + events: [event], + }, + turnNumber: 1, + nextAgentId: world.agents[1]!.id, + turns: [turn], + }); +} + +function jsonResponse(value: unknown) { + return Promise.resolve(new Response(JSON.stringify(value), { status: 200 })); +} + +beforeEach(() => { + mapLibreMock.renderMode = 'complete'; + mapLibreMock.rejectSource = false; + mapLibreMock.rejectLayers = false; + mapLibreMock.duplicateFeatures = false; + mapLibreMock.autoRender = true; + mapLibreMock.pendingRenderCallbacks = []; + mapLibreMock.mapClick = undefined; + mapLibreMock.layers = []; + mapLibreMock.queryRenderedFeatures.mockReset(); + mapLibreMock.setData.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(initial)), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('WorldLab', () => { - it('renders the map shell and reserved developer areas', () => { + it('renders all controls, status, H3 readiness, and six visible markers', async () => { render(); + expect( + await screen.findByText( + /H3 overlay ready · 61\/61 rendered cells · 6 agents/, + ), + ).toBeInTheDocument(); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + '61', + ); + expect(mapLibreMock.queryRenderedFeatures).toHaveBeenCalledWith({ + layers: ['development-hex-fills'], + }); expect( screen.getByRole('heading', { name: 'World Lab' }), ).toBeInTheDocument(); - expect(screen.getByTestId('world-map')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + expect(screen.getByLabelText('Playback speed')).toBeInTheDocument(); + expect( + screen.getAllByRole('button', { name: /Select agent/ }), + ).toHaveLength(6); + expect(screen.getByText('Automated-test provider')).toBeInTheDocument(); + }); + + it('deduplicates rendered H3 features before reporting readiness', async () => { + mapLibreMock.duplicateFeatures = true; + render(); + expect( + await screen.findByText(/H3 overlay ready · 61\/61 rendered cells/), + ).toBeInTheDocument(); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + '61', + ); + }); + + it('waits for an H3 source update and render cycle before inspecting readiness', async () => { + mapLibreMock.autoRender = false; + render(); + await screen.findByRole('button', { name: 'Select agent Ember' }); + expect(screen.getByText(/H3 overlay initializing/)).toBeInTheDocument(); + expect(mapLibreMock.queryRenderedFeatures).not.toHaveBeenCalled(); + + act(() => { + const pendingRenders = mapLibreMock.pendingRenderCallbacks.splice(0); + for (const completeRender of pendingRenders) { + completeRender(); + } + }); + expect( - screen.getByRole('heading', { name: 'Simulation controls' }), + await screen.findByText(/H3 overlay ready · 61\/61 rendered cells/), ).toBeInTheDocument(); + }); + + it.each([ + { + scenario: 'incomplete rendering', + configure: () => (mapLibreMock.renderMode = 'incomplete'), + expectedStatus: 'incomplete', + expectedCount: '60', + }, + { + scenario: 'rejected layers', + configure: () => (mapLibreMock.rejectLayers = true), + expectedStatus: 'failed', + expectedCount: '0', + }, + ])( + 'does not report readiness for $scenario', + async ({ configure, expectedCount, expectedStatus }) => { + configure(); + render(); + expect( + await screen.findByText(/H3 overlay (?:incomplete|failed)/), + ).toBeInTheDocument(); + expect(screen.queryByText(/H3 overlay ready/)).not.toBeInTheDocument(); + expect(screen.getByTestId('world-map')).not.toHaveAttribute( + 'data-overlay-status', + 'ready', + ); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-overlay-status', + expectedStatus, + ); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + expectedCount, + ); + }, + ); + + it('uses explicit boolean assertions in every conditional paint expression', async () => { + render(); + await screen.findByText(/H3 overlay ready/); + const conditions = mapLibreMock.layers.flatMap(({ paint = {} }) => + Object.values(paint) + .filter( + (expression): expression is unknown[] => + Array.isArray(expression) && expression[0] === 'case', + ) + .map((expression) => expression[1]), + ); + expect(conditions).toHaveLength(3); + expect(conditions).toEqual( + Array(3).fill(['boolean', ['get', 'selected'], false]), + ); + }); + + it('selects an agent and populates its inspector', async () => { + const user = userEvent.setup(); + render(); + await user.click( + await screen.findByRole('button', { name: 'Select agent Rook' }), + ); + expect(screen.getByRole('heading', { name: /Rook/ })).toBeInTheDocument(); + expect(screen.getByText(world.agents[1]!.personality)).toBeInTheDocument(); + expect(screen.getByText(world.agents[1]!.id)).toBeInTheDocument(); + }); + + it('executes one turn and renders infection and decision details safely', async () => { + const changed = afterInfection(); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(initial)) + .mockImplementationOnce(() => + jsonResponse({ snapshot: changed, turn: changed.turns[0] }), + ), + ); + const user = userEvent.setup(); + render(); + await user.click( + await screen.findByRole('button', { name: 'Single turn' }), + ); expect( - screen.getByRole('heading', { name: 'Agent inspector' }), + await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); + expect(screen.getByText('Infecting this open cell.')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ), + ); + expect(screen.getByTestId('infected-count')).toHaveTextContent( + '1 rendered infected', + ); + expect(mapLibreMock.setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ state: 'infected' }), + }), + ]), + }), + ); + expect(screen.queryByText(/chain-of-thought/i)).not.toBeInTheDocument(); + }); + + it('starts, pauses, and changes playback speed without overlapping immediately', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Start' })); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText('Playback speed'), '250'); + await user.click(screen.getByRole('button', { name: 'Pause' })); + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); }); - it('selects another hex and reports its H3 index and state', async () => { + it('resets turn history and UI selections', async () => { + const changed = afterInfection(); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(changed)) + .mockImplementationOnce(() => jsonResponse({ snapshot: initial })), + ); const user = userEvent.setup(); render(); - const secondHex = screen.getByRole('button', { name: 'Select hex 2' }); - expect(secondHex).toHaveAttribute('aria-pressed', 'false'); - await user.click(secondHex); - expect(secondHex).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ), + ); + expect( + await screen.findByText('Infection · ' + world.agents[0]!.currentCell), + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Reset' })); + await waitFor(() => + expect( + screen.getByText('Development world loaded with six agents.'), + ).toBeInTheDocument(), + ); + expect(screen.getByText('Turn 0')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ), + ); + }); + + it('supports hex selection independently of agent selection', async () => { + render(); + await screen.findByRole('button', { name: 'Select agent Ember' }); + const target = world.hexes[1]!.cell; + act(() => + mapLibreMock.mapClick?.({ + features: [{ properties: { cell: target } }], + }), + ); + expect(screen.getByText(target)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Ember/ })).toBeInTheDocument(); + }); + + it('renders missing configuration without enabling cost-incurring controls', async () => { + const unconfigured = simulationSnapshotSchema.parse({ + ...initial, + status: 'configuration-error', + providerMode: 'openrouter', + providerConfigured: false, + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(unconfigured)), + ); + render(); expect( - screen.getByText(secondHex.getAttribute('title')!), + await screen.findByText(/Model calls unavailable/), ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Single turn' })).toBeDisabled(); }); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index bba6f47..faff557 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -1,23 +1,144 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { createDevelopmentWorld } from '@agentborne/world-engine'; -import type { H3Cell } from '@agentborne/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, + type AgentId, + type AgentTurnRecord, + type H3Cell, + type SimulationSnapshot, +} from '@agentborne/shared'; import { WorldMap } from './world-map'; const latitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LATITUDE ?? 41.6528); const longitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LONGITUDE ?? -83.5379); const resolution = Number(process.env.NEXT_PUBLIC_DEV_MAP_H3_RESOLUTION ?? 9); +const apiBase = + process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; export function WorldLab() { - const world = useMemo( - () => createDevelopmentWorld({ latitude, longitude, resolution }), - [], + const [snapshot, setSnapshot] = useState(null); + const [selectedCell, setSelectedCell] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [running, setRunning] = useState(false); + const [inFlight, setInFlight] = useState(false); + const [resetting, setResetting] = useState(false); + const [speed, setSpeed] = useState(1_000); + const [uiError, setUiError] = useState(null); + const inFlightRef = useRef(false); + + const applySnapshot = useCallback((next: SimulationSnapshot) => { + setSnapshot(next); + setSelectedCell((current) => current ?? next.world.hexes[0]!.cell); + setSelectedAgentId((current) => current ?? next.world.agents[0]!.id); + }, []); + + useEffect(() => { + let alive = true; + void fetch(apiBase) + .then(async (response) => { + if (!response.ok) throw new Error('The Game API is unavailable.'); + return simulationSnapshotSchema.parse(await response.json()); + }) + .then((next) => { + if (alive) applySnapshot(next); + }) + .catch(() => { + if (alive) + setUiError('The Game API is unavailable. Start it with pnpm dev.'); + }); + return () => { + alive = false; + }; + }, [applySnapshot]); + + const executeTurn = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setInFlight(true); + setUiError(null); + try { + const response = await fetch(`${apiBase}/turn`, { method: 'POST' }); + if (response.status === 409) { + setUiError('Another turn is already in progress.'); + return; + } + if (!response.ok) throw new Error('turn request failed'); + const payload = singleTurnResponseSchema.parse(await response.json()); + applySnapshot(payload.snapshot); + } catch { + setUiError('The turn failed safely. Check the Game API and try again.'); + setRunning(false); + } finally { + inFlightRef.current = false; + setInFlight(false); + } + }, [applySnapshot]); + + useEffect(() => { + if (!running || inFlight || resetting) return; + const timer = window.setTimeout(() => void executeTurn(), speed); + return () => window.clearTimeout(timer); + }, [executeTurn, inFlight, resetting, running, snapshot?.turnNumber, speed]); + + const reset = async () => { + if (inFlightRef.current) return; + setRunning(false); + setResetting(true); + setUiError(null); + try { + const response = await fetch(`${apiBase}/reset`, { method: 'POST' }); + if (response.status === 409) { + setUiError('Reset is unavailable until the current turn completes.'); + return; + } + if (!response.ok) throw new Error('reset request failed'); + const payload = resetSimulationResponseSchema.parse( + await response.json(), + ); + setSnapshot(payload.snapshot); + setSelectedCell(payload.snapshot.world.hexes[0]!.cell); + setSelectedAgentId(payload.snapshot.world.agents[0]!.id); + } catch { + setUiError('Reset failed safely. The existing world was left intact.'); + } finally { + setResetting(false); + } + }; + + if (!snapshot) { + return ( +
+

World Lab

+

{uiError ?? 'Loading simulation…'}

+
+ ); + } + + const selectedAgent = snapshot.world.agents.find( + ({ id }) => id === selectedAgentId, ); - const [selectedCell, setSelectedCell] = useState( - world.hexes[0]!.cell, + const selectedHex = snapshot.world.hexes.find( + ({ cell }) => cell === selectedCell, + ); + const latestTurn = selectedAgent + ? snapshot.turns.findLast(({ agentId }) => agentId === selectedAgent.id) + : undefined; + const status = resetting + ? 'resetting' + : inFlight + ? 'waiting-for-model' + : snapshot.status === 'configuration-error' || + snapshot.status === 'provider-error' + ? snapshot.status + : running + ? 'running' + : 'paused'; + const nextAgent = snapshot.world.agents.find( + ({ id }) => id === snapshot.nextAgentId, ); - const selectedHex = world.hexes.find(({ cell }) => cell === selectedCell)!; return (
@@ -26,9 +147,16 @@ export function WorldLab() {

Developer simulation interface

World Lab

-

-

+
+

+

+

+ {snapshot.providerMode === 'openrouter' + ? 'Genuine model · OpenRouter' + : 'Automated-test provider'} +

+
@@ -36,88 +164,286 @@ export function WorldLab() {
Development location: Toledo, Ohio - H3 resolution {resolution} · {world.hexes.length} cells + H3 resolution {resolution} · {snapshot.world.hexes.length} cells
); } + +function AgentInspector({ + agent, + cellState, + latestTurn, + turns, +}: { + agent: SimulationSnapshot['world']['agents'][number]; + cellState: 'open' | 'infected'; + latestTurn?: AgentTurnRecord; + turns: AgentTurnRecord[]; +}) { + return ( +
+

Agent inspector

+

+ + {agent.name} +

+
+
+
Stable ID
+
{agent.id}
+
+
+
Color
+
{agent.color}
+
+
+
Cell
+
{agent.currentCell}
+
+
+
Cell state
+
{cellState}
+
+
+

Personality

+

{agent.personality}

+ {latestTurn ? ( +
+

Latest turn

+

+ {latestTurn.outcome} +

+ {latestTurn.outcome !== 'provider-error' ? ( + <> +

+ Requested:{' '} + {formatAction(latestTurn.requestedAction)} +

+

+ Summary: {latestTurn.summary} +

+ {latestTurn.outcome === 'rejected' && ( +

+ Rejection: {latestTurn.validation.reason} ·{' '} + {latestTurn.validation.details} +

+ )} +

+ {latestTurn.provider.provider} · {latestTurn.provider.model} ·{' '} + {latestTurn.provider.latencyMs}ms +

+ + ) : ( + <> +

+ {latestTurn.failure.code}: {latestTurn.failure.message} +

+ {latestTurn.provider && ( +

+ {latestTurn.provider.provider} · {latestTurn.provider.model} ·{' '} + {latestTurn.provider.latencyMs}ms +

+ )} + + )} +
+ Latest structured observation +

+ Current: {latestTurn.observation.currentCell.cell} ( + {latestTurn.observation.currentCell.state}) +

+

+ Adjacent:{' '} + {latestTurn.observation.adjacentCells + .map(({ cell, state }) => `${cell} (${state})`) + .join(', ')} +

+

+ Nearby:{' '} + {latestTurn.observation.nearbyAgents + .map(({ name, distance }) => `${name} (${distance})`) + .join(', ') || 'none'} +

+

+ Recent public events: {latestTurn.observation.recentEvents.length} +

+
+
+ ) : ( +

No completed turn for this agent yet.

+ )} +

Recent records

+
    + {turns + .slice(-5) + .toReversed() + .map((turn) => ( +
  1. + Turn {turn.turnNumber}: {turn.outcome} +
  2. + ))} +
+
+ ); +} + +function EventLog({ turns }: { turns: AgentTurnRecord[] }) { + return ( +
+

World events

+

Event log

+
    + {turns.length === 0 ? ( +
  1. + + Development world loaded with six agents. +
  2. + ) : ( + turns + .slice(-20) + .toReversed() + .map((turn) => ( +
  3. + + {formatTurn(turn)} +
  4. + )) + )} +
+
+ ); +} + +function formatAction( + action: Extract< + AgentTurnRecord, + { outcome: 'accepted' | 'rejected' } + >['requestedAction'], +) { + return action.type === 'move' ? `move → ${action.targetCell}` : action.type; +} + +function formatTurn(turn: AgentTurnRecord) { + if (turn.outcome === 'provider-error') + return `Provider failure · ${turn.failure.message}`; + if (turn.outcome === 'rejected') + return `Rejected ${formatAction(turn.requestedAction)} · ${turn.validation.reason}`; + if (turn.event.type === 'agent-moved') + return `Movement · ${turn.event.toCell}`; + if (turn.event.type === 'hex-infected') + return `Infection · ${turn.event.cell}`; + return 'Waited'; +} diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx index a28b727..d9b7762 100644 --- a/apps/world-lab/src/components/world-map.tsx +++ b/apps/world-lab/src/components/world-map.tsx @@ -1,25 +1,58 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { cellToBoundary } from 'h3-js'; +import { useEffect, useRef, useState } from 'react'; +import { cellToBoundary, cellToLatLng } from 'h3-js'; import { AttributionControl, + LngLatBounds, type GeoJSONSource, Map, + type MapLayerMouseEvent, + type MapSourceDataEvent, + Marker, NavigationControl, + setWorkerUrl, } from 'maplibre-gl'; -import type { H3Cell, HexState } from '@agentborne/shared'; +import type { + AgentId, + AgentProfile, + H3Cell, + HexState, +} from '@agentborne/shared'; interface WorldMapProps { latitude: number; longitude: number; hexes: Array<{ cell: H3Cell; state: HexState }>; + agents: AgentProfile[]; selectedCell: H3Cell; - onSelect: (cell: H3Cell) => void; + selectedAgentId: AgentId | null; + onSelectCell: (cell: H3Cell) => void; + onSelectAgent: (agentId: AgentId) => void; } const sourceId = 'development-hexes'; const fillLayerId = 'development-hex-fills'; +const lineLayerId = 'development-hex-lines'; +const expectedWorldCellCount = 61; + +setWorkerUrl('/maplibre-worker/maplibre-gl-worker.mjs'); + +type OverlayStatus = 'initializing' | 'ready' | 'incomplete' | 'failed'; + +interface OverlayDiagnostics { + status: OverlayStatus; + renderedCellCount: number; + renderedInfectedCellCount: number; + detail: string; +} + +const initialOverlayDiagnostics: OverlayDiagnostics = { + status: 'initializing', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'Waiting for the H3 source and layers to render.', +}; function asGeoJson(hexes: WorldMapProps['hexes'], selectedCell: H3Cell) { return { @@ -41,29 +74,46 @@ function closedBoundary(cell: H3Cell): number[][] { return first ? [...coordinates, first] : coordinates; } -export function WorldMap({ - latitude, - longitude, - hexes, - selectedCell, - onSelect, -}: WorldMapProps) { +export function WorldMap(props: WorldMapProps) { + const { + latitude, + longitude, + hexes, + agents, + selectedCell, + selectedAgentId, + onSelectCell, + onSelectAgent, + } = props; const containerRef = useRef(null); const mapRef = useRef(null); - const onSelectRef = useRef(onSelect); + const markersRef = useRef([]); + const onSelectCellRef = useRef(onSelectCell); + const onSelectAgentRef = useRef(onSelectAgent); + const initialHexes = useRef(hexes); const initialSelectedCell = useRef(selectedCell); + const currentHexesRef = useRef(hexes); + const scheduleOverlayInspectionRef = useRef<(() => void) | null>(null); + const [mapReady, setMapReady] = useState(false); + const [overlayDiagnostics, setOverlayDiagnostics] = useState( + initialOverlayDiagnostics, + ); useEffect(() => { - onSelectRef.current = onSelect; - }, [onSelect]); + currentHexesRef.current = hexes; + }, [hexes]); useEffect(() => { - if (!containerRef.current || mapRef.current) return; + onSelectCellRef.current = onSelectCell; + onSelectAgentRef.current = onSelectAgent; + }, [onSelectAgent, onSelectCell]); + useEffect(() => { + if (!containerRef.current || mapRef.current) return; const map = new Map({ container: containerRef.current, center: [longitude, latitude], - zoom: 14, + zoom: 13, attributionControl: false, style: { version: 8, @@ -83,58 +133,269 @@ export function WorldMap({ mapRef.current = map; map.addControl(new NavigationControl({ showCompass: false }), 'top-right'); map.addControl(new AttributionControl({ compact: true })); + const inspectOverlay = () => { + if ( + !map.getSource(sourceId) || + !map.getLayer(fillLayerId) || + !map.getLayer(lineLayerId) + ) { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre rejected the H3 source or one of its layers.', + }); + return; + } - map.on('load', () => { - map.addSource(sourceId, { - type: 'geojson', - data: asGeoJson(hexes, initialSelectedCell.current), - }); - map.addLayer({ - id: fillLayerId, - type: 'fill', - source: sourceId, - paint: { - 'fill-color': [ - 'match', - ['get', 'state'], - 'infected', - '#d45749', - '#4f7b75', - ], - 'fill-opacity': ['case', ['get', 'selected'], 0.76, 0.4], - 'fill-outline-color': [ - 'case', - ['get', 'selected'], - '#f6e8c9', - '#d8dfd8', - ], - }, - }); - map.on('click', fillLayerId, (event) => { - const cell = event.features?.[0]?.properties?.cell; - if (typeof cell === 'string') onSelectRef.current(cell as H3Cell); - }); - map.on('mouseenter', fillLayerId, () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', fillLayerId, () => { - map.getCanvas().style.cursor = ''; - }); - }); + if (!map.isSourceLoaded(sourceId)) { + return; + } + + try { + const expectedCells = new Set( + currentHexesRef.current.map(({ cell }) => cell), + ); + const renderedCells = new globalThis.Map(); + for (const feature of map.queryRenderedFeatures({ + layers: [fillLayerId], + })) { + const cell = feature.properties?.cell; + const state = feature.properties?.state; + if ( + typeof cell === 'string' && + (state === 'open' || state === 'infected') + ) { + renderedCells.set(cell as H3Cell, state); + } + } + const renderedCellCount = renderedCells.size; + const renderedInfectedCellCount = [...renderedCells.values()].filter( + (state) => state === 'infected', + ).length; + const ready = + expectedCells.size === expectedWorldCellCount && + renderedCellCount === expectedWorldCellCount && + [...expectedCells].every((cell) => renderedCells.has(cell)); + setOverlayDiagnostics({ + status: ready ? 'ready' : 'incomplete', + renderedCellCount, + renderedInfectedCellCount, + detail: ready + ? 'All expected H3 cells were returned by MapLibre.' + : `MapLibre rendered ${renderedCellCount} of ${expectedWorldCellCount} expected H3 cells.`, + }); + } catch { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre could not inspect the rendered H3 layer.', + }); + } + }; + let inspectionPending = false; + const inspectAfterRender = () => { + if (!inspectionPending) return; + if ( + !map.getSource(sourceId) || + !map.getLayer(fillLayerId) || + !map.getLayer(lineLayerId) + ) { + inspectionPending = false; + inspectOverlay(); + return; + } + if (!map.isSourceLoaded(sourceId)) return; + inspectionPending = false; + inspectOverlay(); + }; + const scheduleOverlayInspection = () => { + inspectionPending = true; + map.triggerRepaint(); + }; + const inspectLoadedH3Source = (event: MapSourceDataEvent) => { + if (event.sourceId === sourceId && inspectionPending) { + map.triggerRepaint(); + } + }; + const selectRenderedCell = (event: MapLayerMouseEvent) => { + const cell = event.features?.[0]?.properties?.cell; + if (typeof cell === 'string') onSelectCellRef.current(cell as H3Cell); + }; + const showCellCursor = () => { + map.getCanvas().style.cursor = 'pointer'; + }; + const clearCellCursor = () => { + map.getCanvas().style.cursor = ''; + }; + const initializeOverlay = () => { + try { + map.addSource(sourceId, { + type: 'geojson', + data: asGeoJson(initialHexes.current, initialSelectedCell.current), + }); + map.addLayer({ + id: fillLayerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': [ + 'match', + ['get', 'state'], + 'infected', + '#e44f45', + '#4a8178', + ], + 'fill-opacity': [ + 'case', + ['boolean', ['get', 'selected'], false], + 0.72, + 0.38, + ], + }, + }); + map.addLayer({ + id: lineLayerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': [ + 'case', + ['boolean', ['get', 'selected'], false], + '#fff2c9', + '#b8d4cc', + ], + 'line-opacity': 0.95, + 'line-width': [ + 'case', + ['boolean', ['get', 'selected'], false], + 4, + 1.25, + ], + }, + }); + map.on('click', fillLayerId, selectRenderedCell); + map.on('mouseenter', fillLayerId, showCellCursor); + map.on('mouseleave', fillLayerId, clearCellCursor); + + const bounds = new LngLatBounds(); + for (const { cell } of initialHexes.current) { + for (const [lat, lng] of cellToBoundary(cell)) { + bounds.extend([lng, lat]); + } + } + map.fitBounds(bounds, { padding: 56, maxZoom: 14, duration: 0 }); + scheduleOverlayInspectionRef.current = scheduleOverlayInspection; + scheduleOverlayInspection(); + } catch { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre rejected the H3 source or layer configuration.', + }); + } finally { + setMapReady(true); + } + }; + + map.on('sourcedata', inspectLoadedH3Source); + map.on('render', inspectAfterRender); + map.on('load', initializeOverlay); return () => { + scheduleOverlayInspectionRef.current = null; + map.off('load', initializeOverlay); + map.off('sourcedata', inspectLoadedH3Source); + map.off('render', inspectAfterRender); + map.off('click', fillLayerId, selectRenderedCell); + map.off('mouseenter', fillLayerId, showCellCursor); + map.off('mouseleave', fillLayerId, clearCellCursor); + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; map.remove(); mapRef.current = null; }; - }, [hexes, latitude, longitude]); + }, [latitude, longitude]); useEffect(() => { const source = mapRef.current?.getSource(sourceId) as GeoJSONSource | undefined; - source?.setData(asGeoJson(hexes, selectedCell)); + if (!source) return; + source.setData(asGeoJson(hexes, selectedCell)); + scheduleOverlayInspectionRef.current?.(); }, [hexes, selectedCell]); + useEffect(() => { + const map = mapRef.current; + if (!map || !mapReady) return; + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + + const grouped = new globalThis.Map(); + for (const agent of agents) { + grouped.set(agent.currentCell, [ + ...(grouped.get(agent.currentCell) ?? []), + agent, + ]); + } + for (const agent of agents) { + const cellmates = grouped + .get(agent.currentCell)! + .toSorted((a, b) => a.id.localeCompare(b.id)); + const position = cellmates.findIndex(({ id }) => id === agent.id); + const angle = (position / Math.max(cellmates.length, 1)) * Math.PI * 2; + const distance = cellmates.length > 1 ? 15 : 0; + const element = document.createElement('button'); + element.type = 'button'; + element.className = `agent-marker${agent.id === selectedAgentId ? ' selected' : ''}`; + element.dataset.agentId = agent.id; + element.setAttribute('aria-label', `Select agent ${agent.name}`); + element.title = `${agent.name} · ${agent.currentCell}`; + element.style.setProperty('--agent-color', agent.color); + element.textContent = agent.name.slice(0, 1); + element.addEventListener('click', (event) => { + event.stopPropagation(); + onSelectAgentRef.current(agent.id); + }); + const [lat, lng] = cellToLatLng(agent.currentCell); + const marker = new Marker({ + element, + offset: [Math.cos(angle) * distance, Math.sin(angle) * distance], + }) + .setLngLat([lng, lat]) + .addTo(map); + markersRef.current.push(marker); + } + }, [agents, mapReady, selectedAgentId]); + + const overlayReady = overlayDiagnostics.status === 'ready'; + const overlayLabel = overlayReady + ? 'ready' + : overlayDiagnostics.status === 'initializing' + ? 'initializing' + : overlayDiagnostics.status; + return ( -
+
+
+

+ H3 overlay {overlayLabel} · {overlayDiagnostics.renderedCellCount}/ + {expectedWorldCellCount} rendered cells · {agents.length} agents ·{' '} + + {overlayDiagnostics.renderedInfectedCellCount} rendered infected + +

+
); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b5387d2..cb37040 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,30 +1,41 @@ # Architecture -Agentborne begins as a TypeScript monorepo with two deployable applications and three domain packages. The split keeps the world rules independent of frameworks while leaving clean seams for the model-backed work in PR 2. +The PR 2 vertical slice preserves a one-way trust boundary: ```text -World Lab ─────┐ - ├── shared schemas -Game API ──────┤ - ├── world engine ── H3 -Agent runtime ─┘ ▲ - │ structured action request only +World Lab → Game API / simulation service → agent runtime → requested action + ↓ + snapshot / turn record ← world engine validation ``` ## Applications -`apps/world-lab` is a Next.js App Router application. It renders a MapLibre basemap and an H3 GeoJSON overlay. The current development snapshot is created locally so the shell has no service-start dependency. As API-backed state arrives, the UI should consume the same shared response schemas rather than duplicate contracts. +`apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one turn at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. -`apps/game-api` is a Hono service on Node. It currently proves the process and HTTP boundary with health, development-world, not-found, and error shapes. It has no persistence, authentication, scheduler, or player routes. +`apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, monotonic completed-turn count, turn cursor, bounded histories, and overlap lock. It exposes: -## Packages and flow +- `GET /api/simulation` — current authoritative snapshot +- `POST /api/simulation/turn` — one agent observation, one provider decision, one engine application +- `POST /api/simulation/reset` — deterministic reset, rejected while a turn is active -`packages/shared` owns branded identifiers, action/event schemas, snapshots, and error envelopes. These are runtime Zod schemas first, with TypeScript types inferred from them. +The legacy `GET /api/development-world` and `GET /health` endpoints remain for low-level diagnostics. -`packages/world-engine` validates requested actions and returns a new world state plus either a world event or a machine-readable rejection. It owns consequences; callers and model providers do not. The package has no network, UI, model, or database dependency. +## Turn flow -`packages/agent-runtime` defines structured observations and decisions. A provider receives an immutable observation and returns a requested action plus a concise summary. The included scripted provider is a deterministic test seam, not a simulation and not a substitute for PR 2's genuine model-backed agents. +The development world is an H3 radius-four disk (61 cells) around Toledo with six fixed profiles and starting cells. One agent acts per turn in stable array order. The service reads the latest state, constructs and clones a bounded observation, requests one structured decision, validates it with Zod, and passes only the requested action to the world engine. -## Current state model +The engine alone accepts or rejects actions and creates world events. Rejections do not mutate state and are not replaced by heuristics. The service schema-validates a complete accepted or rejected turn record before atomically committing its candidate world state, turn record, completed-turn count, and round-robin cursor. Unexpected engine, schema, or internal failures propagate as internal API errors and commit none of those changes; cleanup always returns the service to a coherent idle state. -The in-memory world contains H3 cells (`open` or `infected`), named agents with a current cell, and append-only world events. Infection persists independently from an agent's position. Messages are validated for recipient and range, but inboxes and relationship memory are intentionally deferred to PR 4. +Only failures thrown by the provider decision call become sanitized provider-failure turn records. Those failures remain non-mutating, count as completed recorded turns, advance round robin, and do not prevent later turns. The public turn number is the total completed-turn count and is independent of the retained history: snapshots keep only the newest 120 turn records and newest 120 world events. Observations select the newest eight relevant public events from that retained event history in chronological order. + +## Packages + +`packages/shared` owns all public schemas: profiles, observations, PR 2 decisions, provider metadata/failures, turn outcomes, snapshots, and API responses. Types are inferred from Zod. + +`packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. Its broader domain action union retains range-limited messaging for the established boundary, but PR 2's model decision schema excludes messaging entirely. + +`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider defaults to `google/gemini-3.7-flash`, uses one strict root-object JSON Schema request with a nested `anyOf` action union and single-value discriminator enums, and requires provider parameter support. The bounded request uses `max_tokens: 1024` plus low-effort reasoning excluded from the response; it does not request, retain, or expose reasoning content. Zod and the world engine remain authoritative for detailed string, H3, action, adjacency, infection-state, and consequence validation. + +The provider abort timeout covers the complete response lifecycle, including body reading, JSON decoding, response extraction, and schema validation, and is cleared after every outcome. Non-success responses retain only bounded, sanitized in-process diagnostics for the opt-in CLI smoke; those details do not enter simulation records or API responses. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. + +The rationale and deferrals are recorded in [ADR 0002](adr/0002-first-visible-llm-invasion.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9ac2f89..1a3fe06 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,20 +1,24 @@ # Security and trust boundaries -## Secrets +## Secrets and deployment -PR 1 requires no secrets. Keep local values in ignored `.env.local` files and document every supported non-secret setting in `.env.example`. Never commit provider keys or expose server credentials through `NEXT_PUBLIC_` variables. Future credentials belong only in server-side provider adapters and the deployment secret store. +`OPENROUTER_API_KEY` is read only by the Game API process. It must never use a `NEXT_PUBLIC_` name, enter a shared schema, reach an API response, or appear in logs. The repository-root `.env` is ignored; `.env.example` contains only a placeholder. Provider errors are reduced to bounded codes and generic messages before recording. + +The development API has no authentication, rate limiting, or spending guard. It binds to loopback and its CORS allowlist is limited to the documented local World Lab origins. Do not deploy its cost-incurring turn endpoint to unauthenticated public traffic. ## Model-provider isolation -A model provider may receive a structured observation and return a schema-validated requested action with a concise decision summary. It must never receive a mutable world object or direct database authority. Only the world engine validates an action and applies consequences; provider output cannot teleport agents, invent state, or declare success. +OpenRouter receives one immutable structured observation. It never receives a mutable world object, storage handle, browser credential, or authority to claim success. The returned JSON is parsed and runtime-validated before the deterministic world engine validates and applies the action. + +The request uses strict JSON Schema structured output and provider routing with `require_parameters: true`. Its Gemini-compatible action union uses nested `anyOf` object variants with single-value enums. Timeouts, network failures, HTTP failures, malformed JSON, and schema failures produce sanitized provider-failure records. The adapter never silently substitutes scripted behavior. -## Untrusted agent content +Non-success OpenRouter bodies are read only up to a fixed bound. The adapter extracts a sanitized status, provider code/message, request ID, and selected model for local CLI diagnosis, redacts credentials and observation strings, and discards the raw body. These diagnostics remain in-process and are excluded from turn records, snapshots, browser responses, and normal logs. -Agent messages are untrusted input. Validate length and shape, preserve provenance, encode them as data when shown to another model, and never concatenate them into system/developer instructions. UI rendering must escape message text and must not interpret it as HTML. +## Prompt and reasoning data -## Reasoning data +The model is explicitly instructed to return only one structured action and one concise visible summary, with no hidden reasoning or chain-of-thought. The request selects low reasoning effort and sets `exclude: true`, so reasoning is not returned. The application stores no raw prompts, raw provider payloads, or private reasoning. Inspector and event-log content is rendered as React text. -Do not request, collect, persist, log, or display raw private chain-of-thought. Store only structured observations, structured action requests, concise model-generated decision summaries, validation outcomes, and resulting world events. Logs and inspectors must follow the same rule. +Agent-authored content remains untrusted. Messaging is not exposed to PR 2 providers or UI and remains deferred. ## Reporting diff --git a/docs/TESTING.md b/docs/TESTING.md index 0e1053f..e1238d3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,10 +1,10 @@ # Testing -Default validation is deterministic, offline except for dependency/browser installation and the World Lab basemap requested during manual viewing. No test calls a model provider. +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. -Coding agents write and update appropriate tests, configure automatic GitHub CI, and provide exact validation commands. The repository owner runs local formatting, lint, type checking, tests, builds, and Playwright unless they explicitly request an agent to run validation in that session. Agents may inspect automatic CI after a branch is pushed, but must not claim local checks passed unless the owner supplied the results. +The repository owner runs local validation. Coding agents write tests and inspect GitHub CI but do not run local formatting, linting, type checking, tests, builds, Playwright, or real-provider calls unless explicitly asked. -## Local sequence +## Owner validation sequence ```bash node --version @@ -15,27 +15,23 @@ pnpm lint pnpm typecheck pnpm test pnpm build -pnpm exec playwright install chromium +pnpm exec playwright install chromium # only if Chromium is not already installed pnpm test:e2e +pnpm smoke:openrouter ``` -Playwright starts the World Lab on port 3000 by default. To reuse an existing -development server on another port, run for example -`PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 pnpm test:e2e`. +`pnpm validate` aggregates formatting, lint, type checking, unit/integration tests, and builds. Playwright remains separate. Its web server starts both applications with the explicit deterministic provider; `AGENTBORNE_PROVIDER=scripted` is never an implicit OpenRouter fallback. -`pnpm validate` aggregates formatting, lint, type checking, unit/integration tests, and builds. Browser smoke coverage remains separate because it installs and starts Chromium. +## Coverage -The World Lab typecheck runs `next typegen` before TypeScript. Next.js manages `apps/world-lab/next-env.d.ts`; the file remains in `tsconfig.json` but is ignored by Git so development, builds, and type generation can recreate it without dirtying the working tree. +- Shared schema tests cover valid/invalid observations and decisions, all turn outcomes, response snapshots, 120-record turn and 120-event history bounds, and model-authored string limits. +- World-engine tests cover adjacent and non-adjacent movement, infection persistence, repeated-infection rejection, wait, immutability on rejection, and deterministic 61-cell/six-agent construction. +- Agent-runtime tests recursively inspect the actual strict OpenRouter schema and Gemini-compatible parameters, prompt placement, explicit model overrides, parsing, runtime validation, malformed/unsupported responses, body-inclusive timeouts and timer cleanup, bounded safe HTTP diagnostics, secret non-leakage, missing configuration, and explicit scripted output. +- Simulation-service tests cover deterministic reset, round robin beyond retained history, monotonic total turn numbering, exactly one call per turn, latest-state observations, 120-record turn retention, 120-event retention, accepted/rejected/failure records, atomic rollback on internal validation failure, recovery, and turn/reset concurrency. +- API integration tests validate snapshot, turn, reset, missing configuration, provider failure, internal-failure propagation and recovery, rejection, and typed conflict responses. +- React tests cover controls/statuses, playback configuration, single turn, reset, agent/hex selection, inspector output, H3 readiness, and safe configuration errors. +- Playwright uses real MapLibre feature inspection to verify 61 unique rendered H3 cells, rendered infection counts through a turn and reset, six markers, marker/inspector interaction, Start/Pause, and the explicit automated-test provider. -## Layers +## Real-provider smoke -- Shared schema unit tests cover the four allowed requested-action forms and reject invented verbs. -- World-engine unit tests cover H3 adjacency, movement validation, infection validation, immutable state transitions, and event production. -- Agent-runtime unit tests cover deterministic scripted-provider output and configuration failure. -- API integration tests call the Hono application in memory and validate health, snapshot, and error responses. -- React Testing Library checks the World Lab shell and hex-selection behavior in jsdom. -- Playwright starts the World Lab automatically and checks the browser-visible map shell, controls reservation, inspector reservation, and event log. - -## External services - -No default test may contact a model, database, map tile service, or game API process. The browser smoke test does not assert tile delivery; MapLibre may request public OpenStreetMap development tiles after the shell loads. Future real-provider tests must be explicitly opted into and must never join the default `pnpm test` or CI path. +The separately opted-in `pnpm smoke:openrouter` command makes one bounded OpenRouter call and validates the decision. Node 24's built-in environment parser reads only `OPENROUTER_API_KEY` and `AGENTBORNE_MODEL` from the repository-root `.env` for this command, and file values override stale exported values for the smoke process. A missing file still yields the sanitized missing-configuration failure. The call incurs third-party cost and is intentionally absent from `pnpm test`, `pnpm validate`, Playwright, and CI. On failure, the CLI may print only the bounded safe diagnostic fields retained by the adapter. diff --git a/docs/adr/0002-first-visible-llm-invasion.md b/docs/adr/0002-first-visible-llm-invasion.md new file mode 100644 index 0000000..485c2e5 --- /dev/null +++ b/docs/adr/0002-first-visible-llm-invasion.md @@ -0,0 +1,26 @@ +# ADR 0002: First visible LLM invasion + +- Status: Accepted +- Date: 2026-08-13 + +## Context + +Roadmap PR 2 needs a browser-observable, genuine model-backed simulation without prematurely adding persistence or autonomous infrastructure. Model output is untrusted and must not own world consequences. + +## Decision + +The Game API owns one in-memory development simulation. It reconstructs a deterministic 61-cell Toledo H3 world with six fixed profiles and starting cells on reset or process startup. + +Turns advance one agent at a time in deterministic round-robin order. The application service builds a fresh immutable observation and calls the provider once. OpenRouter is behind `packages/agent-runtime` and returns a strict structured requested action plus a concise visible summary. Only `packages/world-engine` validates and mutates authoritative state. + +Normal interactive development selects OpenRouter and reports missing configuration or provider failure explicitly. A scripted provider exists only as an explicitly selected deterministic test seam. It is not a fallback and is visibly labeled in the World Lab. + +The OpenRouter path targets `google/gemini-3.7-flash` with required parameter support, a bounded `max_tokens` budget, and low-effort reasoning excluded from responses. Its strict root-object response schema expresses the three action variants through a nested `anyOf` with single-value discriminator enums; runtime schemas and the world engine remain authoritative for all detailed validation and consequences. + +Turn records contain bounded observations, actions, summaries, validation results, events, and safe provider metadata. They never contain raw prompts, raw payloads, secrets, or private chain-of-thought. The total completed-turn count is monotonic and separate from the newest 120 retained turn records. The authoritative world likewise retains only its newest 120 events, while observations receive the newest eight relevant events in chronological order. + +Accepted and rejected turn records are fully schema-validated before the service atomically commits the candidate world, record, count, and cursor. Only errors originating in the provider decision call are converted to sanitized, recorded provider failures. Other engine, schema, or internal errors propagate without changing simulation progress, and turn cleanup restores a coherent idle status. OpenRouter's timeout remains active through body reading, decoding, extraction, and runtime validation, with final cleanup on every outcome. Bounded sanitized HTTP diagnostics are available only to the opt-in smoke CLI and never enter simulation records or browser responses. + +## Consequences + +The vertical slice is easy to run and inspect, and individual actions remain video-observable. Process restart loses state. There is no autonomous scheduler, database, queue, authentication, public-deployment protection, long-term memory, or replay durability yet. Those concerns remain later-roadmap work. diff --git a/package.json b/package.json index 48da11c..a2ad1d3 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,15 @@ }, "scripts": { "build": "pnpm -r --if-present build", - "dev": "pnpm --filter @agentborne/world-lab dev", + "dev": "pnpm --parallel --filter @agentborne/game-api --filter @agentborne/world-lab dev", + "dev:test-provider": "AGENTBORNE_PROVIDER=scripted pnpm dev", "dev:api": "pnpm --filter @agentborne/game-api dev", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "pnpm -r --if-present lint", "test": "pnpm -r --if-present test", "test:e2e": "playwright test", + "smoke:openrouter": "pnpm --filter @agentborne/agent-runtime smoke:openrouter", "typecheck": "pnpm -r --if-present typecheck", "validate": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build" }, diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index 731c23d..894a57c 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -9,14 +9,17 @@ "scripts": { "build": "tsc -p tsconfig.json", "lint": "eslint .", + "smoke:openrouter": "tsx src/real-provider-smoke.ts", "test": "vitest run", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@agentborne/shared": "workspace:*", + "h3-js": "4.5.0", "zod": "4.4.3" }, "devDependencies": { + "tsx": "4.23.12", "vitest": "4.1.10" } } diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 708ee51..de55ee8 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -1,33 +1,376 @@ -import { describe, expect, it } from 'vitest'; -import { agentIdSchema, h3CellSchema } from '@agentborne/shared'; -import { ScriptedAgentProvider } from '.'; - -const observation = { - agentId: agentIdSchema.parse('ca0e2b4d-d88f-4c9e-a401-a7b740c6e5af'), - currentCell: h3CellSchema.parse('892a1072893ffff'), - adjacentCells: [], - nearbyAgentIds: [], - recentMessages: [], -}; +import { describe, expect, it, vi } from 'vitest'; +import { agentObservationSchema } from '@agentborne/shared'; +import { + AgentProviderError, + DEFAULT_OPENROUTER_MODEL, + OpenRouterAgentProvider, + ScriptedAgentProvider, + buildOpenRouterRequest, +} from '.'; +import { applyProviderEnvironmentFile } from './provider-environment'; + +const observation = agentObservationSchema.parse({ + agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + agentName: 'Ember', + personality: 'Aggressively infect open cells.', + currentCell: { cell: '892a1072893ffff', state: 'open' }, + adjacentCells: [{ cell: '892a1072883ffff', state: 'open' }], + nearbyAgents: [], + recentEvents: [], +}); + +function response(content: string, status = 200) { + return new Response( + JSON.stringify({ + id: 'request-safe-id', + model: 'google/gemini-3.7-flash', + choices: [{ message: { content } }], + usage: { prompt_tokens: 20, completion_tokens: 12 }, + }), + { status, headers: { 'Content-Type': 'application/json' } }, + ); +} + +function errorResponse({ + status, + code, + message, + requestId = 'safe-request-id', +}: { + status: number; + code?: string | number; + message?: string; + requestId?: string; +}) { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { + 'Content-Type': 'application/json', + 'x-request-id': requestId, + }, + }); +} + +function visitJsonValues( + value: unknown, + visitor: (value: Record) => void, +) { + if (Array.isArray(value)) { + value.forEach((item) => visitJsonValues(item, visitor)); + return; + } + if (typeof value !== 'object' || value === null) return; + const record = value as Record; + visitor(record); + Object.values(record).forEach((item) => visitJsonValues(item, visitor)); +} + +describe('OpenRouterAgentProvider', () => { + it('constructs a Gemini-compatible strict structured-output request', () => { + const request = buildOpenRouterRequest(observation); + expect(request.model).toBe('google/gemini-3.7-flash'); + expect(DEFAULT_OPENROUTER_MODEL).toBe('google/gemini-3.7-flash'); + expect(request.response_format).toMatchObject({ + type: 'json_schema', + json_schema: { strict: true }, + }); + expect(request.provider).toEqual({ require_parameters: true }); + expect(request.stream).toBe(false); + expect(request.max_tokens).toBe(1024); + expect(request).not.toHaveProperty('max_completion_tokens'); + expect(request).not.toHaveProperty('include_reasoning'); + expect(request).not.toHaveProperty('reasoning_effort'); + expect(request.reasoning).toEqual({ effort: 'low', exclude: true }); + expect(request.messages[1]!.content).toContain(observation.personality); + expect(request.messages[0]!.content).toContain('Never produce messages'); + + const schema = request.response_format.json_schema.schema; + expect(schema.type).toBe('object'); + expect(schema.properties.requestedAction).toHaveProperty('anyOf'); + expect(schema.properties.requestedAction.anyOf).toHaveLength(3); + + visitJsonValues(schema, (schemaNode) => { + expect(schemaNode).not.toHaveProperty('oneOf'); + expect(schemaNode).not.toHaveProperty('const'); + if (schemaNode.type !== 'object') return; + expect(schemaNode.additionalProperties).toBe(false); + const properties = schemaNode.properties as + Record | undefined; + expect(properties).toBeDefined(); + expect([...(schemaNode.required as string[])].sort()).toEqual( + Object.keys(properties!).sort(), + ); + }); + }); + + it('preserves an explicit model override', () => { + expect( + buildOpenRouterRequest(observation, 'custom/provider-model').model, + ).toBe('custom/provider-model'); + }); + + it('parses and runtime-validates a structured decision', async () => { + let capturedInit: RequestInit | undefined; + const fetchImplementation: typeof fetch = vi.fn( + async (_input: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return response( + JSON.stringify({ + requestedAction: { type: 'infect' }, + summary: 'Claiming this cell.', + }), + ); + }, + ); + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation, + }); + await expect(provider.decide(observation)).resolves.toMatchObject({ + decision: { requestedAction: { type: 'infect' } }, + metadata: { provider: 'openrouter' }, + }); + expect(JSON.parse(String(capturedInit?.body))).toMatchObject({ + provider: { require_parameters: true }, + }); + expect(fetchImplementation).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['not-json', 'malformed-response'], + [ + JSON.stringify({ requestedAction: { type: 'teleport' }, summary: 'No.' }), + 'unsupported-response', + ], + [ + JSON.stringify({ + requestedAction: { type: 'wait' }, + summary: 'x'.repeat(241), + }), + 'unsupported-response', + ], + ])( + 'rejects invalid provider content without fallback', + async (content, code) => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => response(content)), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code }, + }); + }, + ); + + it.each([ + [400, 'The model provider rejected the request configuration.', false], + [ + 404, + 'The selected model is unavailable or no endpoint supports all required parameters.', + false, + ], + [429, 'The model provider rate limited the request.', true], + [503, 'The model provider is unavailable.', true], + ])( + 'maps HTTP %i to a clear sanitized public failure', + async (status, message, retryable) => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + errorResponse({ + status, + code: 'provider_error', + message: 'Safe provider detail.', + }), + ), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'provider-http', message, retryable }, + diagnostics: { + httpStatus: status, + providerCode: 'provider_error', + providerMessage: 'Safe provider detail.', + requestId: 'safe-request-id', + model: 'google/gemini-3.7-flash', + }, + }); + }, + ); + + it('sanitizes HTTP diagnostics and network errors without leaking sensitive data', async () => { + const key = 'highly-sensitive-key'; + const injectedSensitiveString = 'injected-sensitive-observation'; + const sensitiveObservation = agentObservationSchema.parse({ + ...observation, + personality: injectedSensitiveString, + }); + const httpProvider = new OpenRouterAgentProvider({ + apiKey: key, + fetchImplementation: vi.fn(async () => + errorResponse({ + status: 400, + code: `invalid-${key}`, + message: `Authorization: Bearer ${key}; observation=${injectedSensitiveString}`, + requestId: `request-${key}`, + }), + ), + }); + const networkProvider = new OpenRouterAgentProvider({ + apiKey: key, + fetchImplementation: vi.fn(async () => { + throw new Error(`socket failed with ${key}`); + }), + }); + for (const provider of [httpProvider, networkProvider]) { + try { + await provider.decide( + provider === httpProvider ? sensitiveObservation : observation, + ); + } catch (error) { + expect(error).toBeInstanceOf(AgentProviderError); + expect(String(error)).not.toContain(key); + expect(JSON.stringify(error)).not.toContain(key); + expect(JSON.stringify(error)).not.toContain(injectedSensitiveString); + } + } + }); + + it('reads only a bounded OpenRouter error body', async () => { + const oversizedMessage = 'safe-detail '.repeat(4_000); + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + errorResponse({ + status: 400, + code: 'invalid_request', + message: oversizedMessage, + }), + ), + }); + try { + await provider.decide(observation); + } catch (error) { + expect(error).toBeInstanceOf(AgentProviderError); + expect((error as AgentProviderError).diagnostics?.providerMessage).toBe( + undefined, + ); + } + }); + + it('times out a bounded request', async () => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + timeoutMs: 1, + fetchImplementation: vi.fn( + async (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'timeout' }, + }); + }); + + it('keeps the timeout active while reading the response body', async () => { + vi.useFakeTimers(); + try { + let requestSignal: AbortSignal | null | undefined; + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + timeoutMs: 10, + fetchImplementation: vi.fn(async (_url, init) => { + requestSignal = init?.signal; + return { + ok: true, + status: 200, + headers: new Headers(), + json: () => + new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + } as Response; + }), + }); + const pending = expect( + provider.decide(observation), + ).rejects.toMatchObject({ failure: { code: 'timeout' } }); + + await vi.advanceTimersByTimeAsync(10); + await pending; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('clears the timeout after a successful response', async () => { + vi.useFakeTimers(); + try { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + response( + JSON.stringify({ + requestedAction: { type: 'wait' }, + summary: 'Done.', + }), + ), + ), + }); + + await expect(provider.decide(observation)).resolves.toBeDefined(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('reports missing configuration instead of using a heuristic fallback', async () => { + const provider = new OpenRouterAgentProvider(); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'configuration' }, + }); + }); +}); + +describe('OpenRouter provider environment', () => { + it('loads only server provider values and overrides stale exports', () => { + const environment: Record = { + OPENROUTER_API_KEY: 'exported-key', + }; + applyProviderEnvironmentFile( + [ + 'OPENROUTER_API_KEY=file-key', + 'AGENTBORNE_MODEL=google/gemini-3.7-flash', + 'NEXT_PUBLIC_GAME_API_BASE_URL=https://browser.example', + ].join('\n'), + environment, + ); + expect(environment).toEqual({ + OPENROUTER_API_KEY: 'file-key', + AGENTBORNE_MODEL: 'google/gemini-3.7-flash', + }); + }); +}); describe('ScriptedAgentProvider', () => { - it('returns validated decisions in order without a mutable world dependency', async () => { + it('returns explicitly scripted decisions in order', async () => { const provider = new ScriptedAgentProvider([ - { - requestedAction: { type: 'wait' }, - summary: 'Staying still to observe.', - }, - { - requestedAction: { type: 'infect' }, - summary: 'Marking the current cell.', - }, + { requestedAction: { type: 'wait' }, summary: 'Staying still.' }, + { requestedAction: { type: 'infect' }, summary: 'Infecting.' }, ]); - await expect(provider.decide(observation)).resolves.toMatchObject({ - requestedAction: { type: 'wait' }, + decision: { requestedAction: { type: 'wait' } }, + metadata: { provider: 'scripted-test' }, }); await expect(provider.decide(observation)).resolves.toMatchObject({ - requestedAction: { type: 'infect' }, + decision: { requestedAction: { type: 'infect' } }, }); }); diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index b03b32a..3ca5c5b 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -1,44 +1,506 @@ import { z } from 'zod'; import { - agentIdSchema, - h3CellSchema, - requestedActionSchema, - type RequestedAction, + agentDecisionSchema, + agentObservationSchema, + type AgentDecision, + type AgentObservation, + type ProviderFailure, + type ProviderMetadata, } from '@agentborne/shared'; -export const agentObservationSchema = z.object({ - agentId: agentIdSchema, - currentCell: h3CellSchema, - adjacentCells: z.array(h3CellSchema), - nearbyAgentIds: z.array(agentIdSchema), - recentMessages: z.array(z.string().max(1_000)), -}); -export type AgentObservation = z.infer; +export { applyProviderEnvironmentFile } from './provider-environment'; -export const agentDecisionSchema = z.object({ - requestedAction: requestedActionSchema, - summary: z.string().trim().min(1).max(500), -}); -export type AgentDecision = z.infer; +export const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.7-flash'; +export const OPENROUTER_ENDPOINT = + 'https://openrouter.ai/api/v1/chat/completions'; + +const OPENROUTER_ERROR_BODY_MAX_BYTES = 16_384; +const OPENROUTER_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 240; + +export interface ProviderDecision { + decision: AgentDecision; + metadata: ProviderMetadata; +} -/** - * Providers receive structured observations and return a requested action. - * They never receive a mutable world handle and cannot apply consequences. - */ +/** Providers receive immutable data and never receive a world handle. */ export interface AgentProvider { - decide(observation: AgentObservation): Promise; + readonly mode: 'openrouter' | 'scripted-test'; + readonly model: string; + readonly configured: boolean; + decide(observation: AgentObservation): Promise; +} + +export class AgentProviderError extends Error { + constructor( + readonly failure: ProviderFailure, + readonly metadata?: ProviderMetadata, + readonly diagnostics?: OpenRouterFailureDiagnostics, + ) { + super(failure.message); + this.name = 'AgentProviderError'; + } +} + +export interface OpenRouterFailureDiagnostics { + httpStatus: number; + providerCode?: string; + providerMessage?: string; + requestId?: string; + model: string; +} + +const decisionJsonSchema = { + type: 'object', + additionalProperties: false, + properties: { + requestedAction: { + anyOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['move'] }, + targetCell: { + type: 'string', + }, + }, + required: ['type', 'targetCell'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['infect'] }, + }, + required: ['type'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['wait'] }, + }, + required: ['type'], + }, + ], + }, + summary: { + type: 'string', + }, + }, + required: ['requestedAction', 'summary'], +} as const; + +export function buildOpenRouterRequest( + observationInput: AgentObservation, + model = DEFAULT_OPENROUTER_MODEL, +) { + const observation = agentObservationSchema.parse(observationInput); + return { + model, + messages: [ + { + role: 'system' as const, + content: + 'You control one map agent. Choose exactly one permitted action from the supplied observation. Follow the fixed personality as behavioral guidance. A move target must be copied from adjacentCells. Never produce messages, private chain-of-thought, hidden reasoning, analysis, or extra fields. Return only the strict structured decision and one concise user-visible summary.', + }, + { + role: 'user' as const, + content: JSON.stringify({ + purpose: 'Choose the next action from this immutable observation.', + observation, + }), + }, + ], + response_format: { + type: 'json_schema' as const, + json_schema: { + name: 'agentborne_agent_decision', + strict: true, + schema: decisionJsonSchema, + }, + }, + provider: { require_parameters: true }, + max_tokens: 1024, + reasoning: { + effort: 'low' as const, + exclude: true, + }, + stream: false, + }; +} + +const openRouterResponseSchema = z.object({ + id: z.string().optional(), + model: z.string().optional(), + choices: z.array( + z.object({ + message: z.object({ content: z.string().nullable() }), + }), + ), + usage: z + .object({ + prompt_tokens: z.number().int().nonnegative().optional(), + completion_tokens: z.number().int().nonnegative().optional(), + }) + .optional(), +}); + +export interface OpenRouterProviderOptions { + apiKey?: string; + model?: string; + timeoutMs?: number; + fetchImplementation?: typeof fetch; +} + +export class OpenRouterAgentProvider implements AgentProvider { + readonly mode = 'openrouter' as const; + readonly model: string; + readonly configured: boolean; + readonly #apiKey?: string; + readonly #timeoutMs: number; + readonly #fetch: typeof fetch; + + constructor({ + apiKey, + model = DEFAULT_OPENROUTER_MODEL, + timeoutMs = 15_000, + fetchImplementation = fetch, + }: OpenRouterProviderOptions = {}) { + this.#apiKey = apiKey?.trim() || undefined; + this.model = model.trim() || DEFAULT_OPENROUTER_MODEL; + this.#timeoutMs = timeoutMs; + this.#fetch = fetchImplementation; + this.configured = Boolean(this.#apiKey); + } + + async decide(observation: AgentObservation): Promise { + const started = Date.now(); + if (!this.#apiKey) { + throw new AgentProviderError({ + code: 'configuration', + message: + 'OpenRouter is unavailable. Set OPENROUTER_API_KEY on the Game API server.', + retryable: false, + }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.#timeoutMs); + try { + const providerRequest = buildOpenRouterRequest(observation, this.model); + const requestBody = JSON.stringify(providerRequest); + let response: Response; + try { + response = await this.#fetch(OPENROUTER_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + }, + body: requestBody, + signal: controller.signal, + }); + } catch { + throw requestFailure(controller.signal.aborted); + } + + if (!response.ok) { + const sensitiveValues = collectSensitiveValues({ + apiKey: this.#apiKey, + observation, + additionalValues: [ + requestBody, + ...providerRequest.messages.map(({ content }) => content), + ], + }); + const diagnostics = await readOpenRouterFailureDiagnostics({ + response, + model: this.model, + sensitiveValues, + }).catch(() => undefined); + if (controller.signal.aborted) throw requestFailure(true); + const failure = httpFailure(response.status); + throw new AgentProviderError( + failure, + undefined, + diagnostics ?? { + httpStatus: response.status, + model: + sanitizeDiagnosticMessage(this.model, sensitiveValues, 120) ?? + '[redacted]', + }, + ); + } + + let raw: unknown; + try { + raw = await response.json(); + } catch { + if (controller.signal.aborted) throw requestFailure(true); + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model provider returned malformed JSON.', + retryable: true, + }); + } + + const parsedResponse = openRouterResponseSchema.safeParse(raw); + if (!parsedResponse.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned an unsupported response shape.', + retryable: true, + }); + } + const content = parsedResponse.data.choices[0]?.message.content; + if (!content) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned no structured decision.', + retryable: true, + }); + } + + let decisionInput: unknown; + try { + decisionInput = JSON.parse(content); + } catch { + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model decision was not valid JSON.', + retryable: true, + }); + } + const decision = agentDecisionSchema.safeParse(decisionInput); + if (!decision.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model decision failed runtime schema validation.', + retryable: true, + }); + } + + return { + decision: decision.data, + metadata: { + provider: 'openrouter', + model: parsedResponse.data.model ?? this.model, + requestId: + parsedResponse.data.id ?? + response.headers.get('x-request-id') ?? + undefined, + latencyMs: Date.now() - started, + promptTokens: parsedResponse.data.usage?.prompt_tokens, + completionTokens: parsedResponse.data.usage?.completion_tokens, + }, + }; + } finally { + clearTimeout(timeout); + } + } } -export interface ScriptedDecision { - requestedAction: RequestedAction; - summary: string; +function requestFailure(timedOut: boolean): AgentProviderError { + return new AgentProviderError({ + code: timedOut ? 'timeout' : 'network', + message: timedOut + ? 'The model request timed out.' + : 'The model provider could not be reached.', + retryable: true, + }); +} + +function httpFailure(status: number): ProviderFailure { + if (status === 400) { + return { + code: 'provider-http', + message: 'The model provider rejected the request configuration.', + retryable: false, + }; + } + if (status === 404) { + return { + code: 'provider-http', + message: + 'The selected model is unavailable or no endpoint supports all required parameters.', + retryable: false, + }; + } + if (status === 429) { + return { + code: 'provider-http', + message: 'The model provider rate limited the request.', + retryable: true, + }; + } + if (status >= 500) { + return { + code: 'provider-http', + message: 'The model provider is unavailable.', + retryable: true, + }; + } + return { + code: 'provider-http', + message: `The model provider returned HTTP ${status}.`, + retryable: false, + }; +} + +async function readOpenRouterFailureDiagnostics({ + response, + model, + sensitiveValues, +}: { + response: Response; + model: string; + sensitiveValues: string[]; +}): Promise { + const rawText = await readBoundedText( + response, + OPENROUTER_ERROR_BODY_MAX_BYTES, + ); + let payload: unknown; + try { + payload = JSON.parse(rawText); + } catch { + payload = undefined; + } + const root = asRecord(payload); + const error = asRecord(root?.error); + const metadata = asRecord(error?.metadata); + const providerCode = sanitizeDiagnosticCode(error?.code, sensitiveValues); + const providerMessage = sanitizeDiagnosticMessage( + error?.message, + sensitiveValues, + ); + const requestId = sanitizeDiagnosticCode( + response.headers.get('x-request-id') ?? + root?.request_id ?? + metadata?.request_id, + sensitiveValues, + 160, + ); + return { + httpStatus: response.status, + providerCode, + providerMessage, + requestId, + model: + sanitizeDiagnosticMessage(model, sensitiveValues, 120) ?? + DEFAULT_OPENROUTER_MODEL, + }; +} + +async function readBoundedText( + response: Response, + maximumBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let remaining = maximumBytes; + let output = ''; + try { + while (remaining > 0) { + const { done, value } = await reader.read(); + if (done) break; + const accepted = value.subarray(0, remaining); + output += decoder.decode(accepted, { stream: true }); + remaining -= accepted.byteLength; + if (accepted.byteLength < value.byteLength) { + await reader.cancel(); + remaining = -1; + break; + } + } + if (remaining === 0) await reader.cancel(); + output += decoder.decode(); + return output; + } finally { + reader.releaseLock(); + } +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function collectSensitiveValues({ + apiKey, + observation, + additionalValues, +}: { + apiKey: string; + observation: AgentObservation; + additionalValues: string[]; +}): string[] { + const values = new Set([apiKey]); + const visit = (value: unknown) => { + if (typeof value === 'string') { + if (value.length >= 4) values.add(value); + return; + } + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(visit); + } + }; + visit(observation); + visit(additionalValues); + return [...values].toSorted((left, right) => right.length - left.length); +} + +function sanitizeDiagnosticCode( + value: unknown, + sensitiveValues: string[], + maximumLength = 80, +): string | undefined { + const sanitized = sanitizeDiagnosticMessage( + typeof value === 'number' ? String(value) : value, + sensitiveValues, + maximumLength, + ); + if (!sanitized) return undefined; + return /^[a-zA-Z0-9_.:/-]+$/.test(sanitized) ? sanitized : undefined; +} + +function sanitizeDiagnosticMessage( + value: unknown, + sensitiveValues: string[], + maximumLength = OPENROUTER_DIAGNOSTIC_MESSAGE_MAX_LENGTH, +): string | undefined { + if (typeof value !== 'string') return undefined; + let sanitized = value + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + sanitized = sanitized.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]'); + sanitized = sanitized.replace(/sk-or-[a-zA-Z0-9_-]+/g, '[redacted]'); + for (const sensitiveValue of sensitiveValues) { + sanitized = sanitized.replaceAll(sensitiveValue, '[redacted]'); + } + if (/"(?:authorization|messages|observation)"\s*:/i.test(sanitized)) { + return 'Provider diagnostic details were redacted.'; + } + const bounded = sanitized.slice(0, maximumLength).trim(); + return bounded || undefined; } export class ScriptedAgentProvider implements AgentProvider { - readonly #decisions: ScriptedDecision[]; + readonly mode = 'scripted-test' as const; + readonly model = 'deterministic-script'; + readonly configured = true; + readonly #decisions: AgentDecision[]; #cursor = 0; - constructor(decisions: ScriptedDecision[]) { + constructor(decisions: AgentDecision[]) { if (decisions.length === 0) { throw new Error('ScriptedAgentProvider requires at least one decision.'); } @@ -47,12 +509,56 @@ export class ScriptedAgentProvider implements AgentProvider { ); } - async decide(observation: AgentObservation): Promise { + async decide(observation: AgentObservation): Promise { agentObservationSchema.parse(observation); const decision = this.#decisions[this.#cursor]; - if (!decision) - throw new Error('ScriptedAgentProvider has no decisions remaining.'); + if (!decision) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The deterministic test script has no decision remaining.', + retryable: false, + }); + } this.#cursor += 1; - return structuredClone(decision); + return { + decision: structuredClone(decision), + metadata: { + provider: 'scripted-test', + model: this.model, + latencyMs: 0, + }, + }; + } +} + +/** Explicit deterministic browser/CI provider; never selected by fallback. */ +export class BrowserTestAgentProvider implements AgentProvider { + readonly mode = 'scripted-test' as const; + readonly model = 'deterministic-browser-script'; + readonly configured = true; + + async decide(observationInput: AgentObservation): Promise { + const observation = agentObservationSchema.parse(observationInput); + const requestedAction = + observation.currentCell.state === 'open' + ? ({ type: 'infect' } as const) + : ({ + type: 'move', + targetCell: observation.adjacentCells[0]!.cell, + } as const); + return { + decision: { + requestedAction, + summary: + requestedAction.type === 'infect' + ? 'Infecting this open cell.' + : 'Moving to the first adjacent cell in the test script.', + }, + metadata: { + provider: 'scripted-test', + model: this.model, + latencyMs: 0, + }, + }; } } diff --git a/packages/agent-runtime/src/provider-environment.ts b/packages/agent-runtime/src/provider-environment.ts new file mode 100644 index 0000000..6f65789 --- /dev/null +++ b/packages/agent-runtime/src/provider-environment.ts @@ -0,0 +1,24 @@ +import { parseEnv } from 'node:util'; + +const providerEnvironmentNames = [ + 'OPENROUTER_API_KEY', + 'AGENTBORNE_MODEL', +] as const; + +export function applyProviderEnvironmentFile( + contents: string, + environment: Record = process.env, +): void { + let fileEnvironment: Record; + try { + fileEnvironment = parseEnv(contents); + } catch { + throw new Error('The repository .env file could not be parsed.'); + } + + for (const name of providerEnvironmentNames) { + if (fileEnvironment[name] !== undefined) { + environment[name] = fileEnvironment[name]; + } + } +} diff --git a/packages/agent-runtime/src/real-provider-smoke.ts b/packages/agent-runtime/src/real-provider-smoke.ts new file mode 100644 index 0000000..58af00b --- /dev/null +++ b/packages/agent-runtime/src/real-provider-smoke.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; +import { latLngToCell, gridDisk } from 'h3-js'; +import { + agentIdSchema, + agentObservationSchema, + h3CellSchema, +} from '@agentborne/shared'; +import { + AgentProviderError, + OpenRouterAgentProvider, + applyProviderEnvironmentFile, +} from './index'; + +try { + applyProviderEnvironmentFile( + readFileSync(new URL('../../../.env', import.meta.url), 'utf8'), + ); +} catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } +} + +const currentCell = h3CellSchema.parse(latLngToCell(41.6528, -83.5379, 9)); +const adjacentCells = gridDisk(currentCell, 1) + .filter((cell) => cell !== currentCell) + .map((cell) => ({ cell: h3CellSchema.parse(cell), state: 'open' as const })); + +const provider = new OpenRouterAgentProvider({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.AGENTBORNE_MODEL, +}); +const observation = agentObservationSchema.parse({ + agentId: agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e'), + agentName: 'Ember', + personality: 'Prefer infecting open cells and moving into uninfected space.', + currentCell: { cell: currentCell, state: 'open' }, + adjacentCells, + nearbyAgents: [], + recentEvents: [], +}); + +try { + const result = await provider.decide(observation); + console.log( + JSON.stringify( + { + valid: true, + decision: result.decision, + provider: result.metadata.provider, + model: result.metadata.model, + }, + null, + 2, + ), + ); +} catch (error) { + if (!(error instanceof AgentProviderError)) throw error; + console.error( + JSON.stringify( + { + valid: false, + failure: error.failure, + diagnostics: error.diagnostics, + }, + null, + 2, + ), + ); + process.exitCode = 1; +} diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 868904a..447706c 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,25 +1,174 @@ import { describe, expect, it } from 'vitest'; -import { requestedActionSchema } from './index'; +import { + MODEL_SUMMARY_MAX_LENGTH, + agentDecisionSchema, + agentObservationSchema, + agentTurnRecordSchema, + simulationSnapshotSchema, +} from '.'; -describe('requestedActionSchema', () => { +const agentId = '128f3f38-6b7d-4db7-9e95-751b4ce2681e'; +const cell = '892a1072893ffff'; +const adjacent = '892a1072883ffff'; +const observation = { + agentId, + agentName: 'Ember', + personality: 'Prefer infection.', + currentCell: { cell, state: 'open' }, + adjacentCells: [{ cell: adjacent, state: 'open' }], + nearbyAgents: [], + recentEvents: [], +}; +const baseTurn = { + turnNumber: 1, + agentId, + startedAt: '2026-08-13T12:00:00.000Z', + completedAt: '2026-08-13T12:00:01.000Z', + observation, +}; +const provider = { + provider: 'openrouter', + model: 'google/gemini-3.7-flash', + latencyMs: 100, +}; +const event = { + id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + agentId, + occurredAt: '2026-08-13T12:00:01.000Z', + type: 'hex-infected', + cell, +}; + +describe('agent observation and decision schemas', () => { + it('accepts a bounded state-bearing observation', () => { + expect(agentObservationSchema.parse(observation).currentCell.state).toBe( + 'open', + ); + }); + + it.each([ + { ...observation, adjacentCells: [] }, + { ...observation, currentCell: { cell, state: 'unknown' } }, + { + ...observation, + nearbyAgents: Array(6).fill({ + id: agentId, + name: 'x', + currentCell: cell, + distance: 1, + }), + }, + ])('rejects invalid or oversized observations', (value) => { + expect(agentObservationSchema.safeParse(value).success).toBe(false); + }); + + it.each([ + { + requestedAction: { type: 'move', targetCell: adjacent }, + summary: 'Move.', + }, + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ])('accepts a PR 2 decision', (decision) => { + expect(agentDecisionSchema.safeParse(decision).success).toBe(true); + }); + + it.each([ + { + requestedAction: { type: 'teleport', targetCell: adjacent }, + summary: 'No.', + }, + { + requestedAction: { + type: 'message', + recipientId: agentId, + message: 'No.', + }, + summary: 'No.', + }, + { + requestedAction: { type: 'wait' }, + summary: 'x'.repeat(MODEL_SUMMARY_MAX_LENGTH + 1), + }, + ])('rejects forbidden actions and oversized model text', (decision) => { + expect(agentDecisionSchema.safeParse(decision).success).toBe(false); + }); +}); + +describe('turn and snapshot schemas', () => { it.each([ - { type: 'move', targetCell: '892a1072893ffff' }, - { type: 'infect' }, { - type: 'message', - recipientId: 'ca0e2b4d-d88f-4c9e-a401-a7b740c6e5af', - message: 'Meet me at the edge.', + ...baseTurn, + outcome: 'accepted', + requestedAction: { type: 'infect' }, + summary: 'Infect.', + validation: { accepted: true }, + event, + provider, }, - { type: 'wait' }, - ])('accepts a structured $type action', (action) => { - expect(requestedActionSchema.safeParse(action).success).toBe(true); + { + ...baseTurn, + outcome: 'rejected', + requestedAction: { type: 'move', targetCell: adjacent }, + summary: 'Move.', + validation: { accepted: false, reason: 'not-adjacent', details: 'No.' }, + provider, + }, + { + ...baseTurn, + outcome: 'provider-error', + failure: { code: 'timeout', message: 'Timed out.', retryable: true }, + }, + ])('validates $outcome turn records', (turn) => { + expect(agentTurnRecordSchema.safeParse(turn).success).toBe(true); }); - it('rejects invented action verbs', () => { + it('validates a complete API snapshot and rejects unbounded histories', () => { + const worldAgent = { + id: agentId, + name: 'Ember', + color: '#ff6b57', + personality: 'Prefer infection.', + currentCell: cell, + }; + const snapshot = { + world: { + generatedAt: '2026-08-13T12:00:00.000Z', + hexes: [{ cell, state: 'open' }], + agents: [worldAgent], + events: [], + }, + turnNumber: 0, + nextAgentId: agentId, + activeAgentId: null, + status: 'paused', + providerMode: 'openrouter', + providerConfigured: true, + turns: [], + }; + const validTurn = { + ...baseTurn, + outcome: 'accepted', + requestedAction: { type: 'infect' }, + summary: 'Infect.', + validation: { accepted: true }, + event, + provider, + }; + expect(simulationSnapshotSchema.safeParse(snapshot).success).toBe(true); + expect( + simulationSnapshotSchema.safeParse({ + ...snapshot, + turns: Array(121).fill(validTurn), + }).success, + ).toBe(false); expect( - requestedActionSchema.safeParse({ - type: 'teleport', - targetCell: '892a1072893ffff', + simulationSnapshotSchema.safeParse({ + ...snapshot, + world: { + ...snapshot.world, + events: Array(121).fill(event), + }, }).success, ).toBe(false); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ebc77f9..24b7f5f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +export const MODEL_SUMMARY_MAX_LENGTH = 240; +export const PERSONALITY_MAX_LENGTH = 600; +export const PROVIDER_ERROR_MAX_LENGTH = 240; + export const agentIdSchema = z.uuid().brand<'AgentId'>(); export type AgentId = z.infer; @@ -15,32 +19,31 @@ export type H3Cell = z.infer; export const hexStateSchema = z.enum(['open', 'infected']); export type HexState = z.infer; -export const agentSchema = z.object({ +export const agentProfileSchema = z.object({ id: agentIdSchema, name: z.string().trim().min(1).max(80), + color: z.string().regex(/^#[0-9a-f]{6}$/i), + personality: z.string().trim().min(1).max(PERSONALITY_MAX_LENGTH), currentCell: h3CellSchema, }); +export type AgentProfile = z.infer; + +export const agentSchema = agentProfileSchema; export type Agent = z.infer; export const moveActionSchema = z.object({ type: z.literal('move'), targetCell: h3CellSchema, }); - -export const infectActionSchema = z.object({ - type: z.literal('infect'), -}); - +export const infectActionSchema = z.object({ type: z.literal('infect') }); export const messageActionSchema = z.object({ type: z.literal('message'), recipientId: agentIdSchema, message: z.string().trim().min(1).max(1_000), }); +export const waitActionSchema = z.object({ type: z.literal('wait') }); -export const waitActionSchema = z.object({ - type: z.literal('wait'), -}); - +/** All engine actions. Messaging remains dormant until Roadmap PR 4. */ export const requestedActionSchema = z.discriminatedUnion('type', [ moveActionSchema, infectActionSchema, @@ -49,6 +52,14 @@ export const requestedActionSchema = z.discriminatedUnion('type', [ ]); export type RequestedAction = z.infer; +/** The deliberately smaller action surface available to PR 2 models. */ +export const agentMapActionSchema = z.discriminatedUnion('type', [ + moveActionSchema, + infectActionSchema, + waitActionSchema, +]); +export type AgentMapAction = z.infer; + const worldEventBaseSchema = z.object({ id: eventIdSchema, agentId: agentIdSchema, @@ -87,14 +98,11 @@ export const invalidActionReasonSchema = z.enum([ export type InvalidActionReason = z.infer; export const actionResultSchema = z.discriminatedUnion('accepted', [ - z.object({ - accepted: z.literal(true), - event: worldEventSchema, - }), + z.object({ accepted: z.literal(true), event: worldEventSchema }), z.object({ accepted: z.literal(false), reason: invalidActionReasonSchema, - details: z.string().min(1), + details: z.string().min(1).max(300), }), ]); export type ActionResult = z.infer; @@ -103,14 +111,151 @@ export const worldSnapshotSchema = z.object({ generatedAt: z.iso.datetime(), hexes: z.array(z.object({ cell: h3CellSchema, state: hexStateSchema })), agents: z.array(agentSchema), - events: z.array(worldEventSchema), + events: z.array(worldEventSchema).max(120), }); export type WorldSnapshot = z.infer; +export const cellObservationSchema = z.object({ + cell: h3CellSchema, + state: hexStateSchema, +}); +export type CellObservation = z.infer; + +export const nearbyAgentObservationSchema = z.object({ + id: agentIdSchema, + name: z.string().trim().min(1).max(80), + currentCell: h3CellSchema, + distance: z.number().int().min(0).max(4), +}); + +export const publicEventObservationSchema = z.object({ + type: z.enum(['agent-moved', 'hex-infected', 'agent-waited']), + agentId: agentIdSchema, + occurredAt: z.iso.datetime(), + summary: z.string().trim().min(1).max(180), +}); + +export const agentObservationSchema = z.object({ + agentId: agentIdSchema, + agentName: z.string().trim().min(1).max(80), + personality: z.string().trim().min(1).max(PERSONALITY_MAX_LENGTH), + currentCell: cellObservationSchema, + adjacentCells: z.array(cellObservationSchema).min(1).max(6), + nearbyAgents: z.array(nearbyAgentObservationSchema).max(5), + recentEvents: z.array(publicEventObservationSchema).max(8), +}); +export type AgentObservation = z.infer; + +export const agentDecisionSchema = z.object({ + requestedAction: agentMapActionSchema, + summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH), +}); +export type AgentDecision = z.infer; + +export const providerModeSchema = z.enum(['openrouter', 'scripted-test']); +export type ProviderMode = z.infer; + +export const providerMetadataSchema = z.object({ + provider: providerModeSchema, + model: z.string().trim().min(1).max(120), + requestId: z.string().trim().min(1).max(160).optional(), + latencyMs: z.number().int().nonnegative().max(300_000), + promptTokens: z.number().int().nonnegative().optional(), + completionTokens: z.number().int().nonnegative().optional(), +}); +export type ProviderMetadata = z.infer; + +export const providerFailureSchema = z.object({ + code: z.enum([ + 'configuration', + 'timeout', + 'network', + 'provider-http', + 'malformed-response', + 'unsupported-response', + ]), + message: z.string().trim().min(1).max(PROVIDER_ERROR_MAX_LENGTH), + retryable: z.boolean(), +}); +export type ProviderFailure = z.infer; + +const turnRecordBaseSchema = z.object({ + turnNumber: z.number().int().positive(), + agentId: agentIdSchema, + startedAt: z.iso.datetime(), + completedAt: z.iso.datetime(), + observation: agentObservationSchema, +}); + +const completedTurnFields = { + requestedAction: agentMapActionSchema, + summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH), + provider: providerMetadataSchema, +}; + +export const agentTurnRecordSchema = z.discriminatedUnion('outcome', [ + turnRecordBaseSchema.extend({ + outcome: z.literal('accepted'), + ...completedTurnFields, + validation: z.object({ accepted: z.literal(true) }), + event: worldEventSchema, + }), + turnRecordBaseSchema.extend({ + outcome: z.literal('rejected'), + ...completedTurnFields, + validation: z.object({ + accepted: z.literal(false), + reason: invalidActionReasonSchema, + details: z.string().min(1).max(300), + }), + }), + turnRecordBaseSchema.extend({ + outcome: z.literal('provider-error'), + failure: providerFailureSchema, + provider: providerMetadataSchema.optional(), + }), +]); +export type AgentTurnRecord = z.infer; + +export const simulationStatusSchema = z.enum([ + 'paused', + 'running', + 'waiting-for-model', + 'resetting', + 'configuration-error', + 'provider-error', +]); +export type SimulationStatus = z.infer; + +export const simulationSnapshotSchema = z.object({ + world: worldSnapshotSchema, + turnNumber: z.number().int().nonnegative(), + nextAgentId: agentIdSchema, + activeAgentId: agentIdSchema.nullable(), + status: simulationStatusSchema, + providerMode: providerModeSchema, + providerConfigured: z.boolean(), + turns: z.array(agentTurnRecordSchema).max(120), +}); +export type SimulationSnapshot = z.infer; + +export const singleTurnResponseSchema = z.object({ + snapshot: simulationSnapshotSchema, + turn: agentTurnRecordSchema, +}); +export type SingleTurnResponse = z.infer; + +export const resetSimulationResponseSchema = z.object({ + snapshot: simulationSnapshotSchema, +}); +export type ResetSimulationResponse = z.infer< + typeof resetSimulationResponseSchema +>; + export const apiErrorSchema = z.object({ error: z.object({ - code: z.string().min(1), - message: z.string().min(1), + code: z.string().min(1).max(80), + message: z.string().min(1).max(300), }), }); export type ApiError = z.infer; diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index a677b38..9f7dcc6 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -14,7 +14,13 @@ const adjacent = h3CellSchema.parse(gridDisk(center, 1)[1]); const distant = h3CellSchema.parse( gridDisk(center, 2).find((cell) => gridDistance(center, cell) === 2), ); -const agent: Agent = { id: agentId, name: 'Morrow', currentCell: center }; +const agent: Agent = { + id: agentId, + name: 'Morrow', + color: '#ff6b57', + personality: 'Moves deliberately.', + currentCell: center, +}; const context = { createEventId: () => '67aa21b9-fc78-4b04-9f92-9862bf346f96', now: () => '2026-08-13T12:00:00.000Z', @@ -86,15 +92,71 @@ describe('infection', () => { }); it('rejects repeated infection', () => { - const result = applyRequestedAction( + const infected = applyRequestedAction( stateWithAgent(), agentId, { type: 'infect' }, context, ); + const result = applyRequestedAction( + infected.state, + agentId, + { type: 'infect' }, + context, + ); expect(result.result).toMatchObject({ accepted: false, reason: 'already-infected', }); }); + + it('persists infection after the agent moves away', () => { + const before = stateWithAgent(); + const infected = applyRequestedAction( + before, + agentId, + { type: 'infect' }, + context, + ); + const moved = applyRequestedAction( + infected.state, + agentId, + { type: 'move', targetCell: adjacent }, + context, + ); + expect(moved.state.hexes.get(center)).toBe('infected'); + expect(moved.state.agents.get(agentId)?.currentCell).toBe(adjacent); + }); +}); + +describe('wait and deterministic development world', () => { + it('records a wait without changing cells or hex states', () => { + const before = stateWithAgent(); + const result = applyRequestedAction( + before, + agentId, + { type: 'wait' }, + context, + ); + expect(result.result).toMatchObject({ + accepted: true, + event: { type: 'agent-waited' }, + }); + expect(result.state.hexes).toBe(before.hexes); + expect(result.state.agents).toBe(before.agents); + }); + + it('constructs the same 61 cells and six valid named agents', () => { + const first = createDevelopmentWorld({ generatedAt: context.now() }); + const second = createDevelopmentWorld({ generatedAt: context.now() }); + expect(first).toEqual(second); + expect(first.hexes).toHaveLength(61); + expect(first.agents).toHaveLength(6); + expect(new Set(first.agents.map(({ id }) => id)).size).toBe(6); + expect( + first.agents.every(({ currentCell }) => + first.hexes.some(({ cell }) => cell === currentCell), + ), + ).toBe(true); + }); }); diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index ced043a..c0d7411 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -186,26 +186,80 @@ export interface DevelopmentWorldOptions { latitude?: number; longitude?: number; resolution?: number; + radius?: number; generatedAt?: string; } +export const DEVELOPMENT_AGENT_BLUEPRINTS = [ + { + id: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + name: 'Ember', + color: '#ff6b57', + personality: + 'You are an aggressive infector. Prefer infecting open cells and move decisively toward uninfected space when your current cell is already infected.', + }, + { + id: '2507bb46-7ae4-45ca-8dda-644c4f85ca14', + name: 'Rook', + color: '#ffd166', + personality: + 'You are a restless wanderer. Prefer movement and variety, rarely waiting unless no move seems worthwhile.', + }, + { + id: '3ba3ef0b-2142-44cc-b175-f6e5d6e98df5', + name: 'Mingle', + color: '#63d2ff', + personality: + 'You are curious about other agents. Move toward visible agents and linger near activity, while infecting opportunistically.', + }, + { + id: '442a1667-39c8-48e9-8c89-23803f9e2101', + name: 'Solace', + color: '#c59cff', + personality: + 'You value solitude. Move away from nearby agents, seek quiet cells, and infect only when it helps claim isolated space.', + }, + { + id: '5f812a08-05f2-4950-bf2d-4df59d05e9c2', + name: 'Verge', + color: '#6ee7a8', + personality: + 'You are an edge-seeking explorer. Push toward cells with fewer onward options and favor expanding activity along the world boundary.', + }, + { + id: '67a43b5c-ced8-45bd-970f-a89ac57853fc', + name: 'Jinx', + color: '#ff91c8', + personality: + 'You are an unpredictable opportunist. Mix movement, infection, and occasional waiting based on whatever detail in the observation catches your attention.', + }, +] as const; + export function createDevelopmentWorld({ latitude = 41.6528, longitude = -83.5379, resolution = 9, + radius = 4, generatedAt = new Date().toISOString(), }: DevelopmentWorldOptions = {}): WorldSnapshot { const center = h3CellSchema.parse( latLngToCell(latitude, longitude, resolution), ); - const cells = gridDisk(center, 2).map((cell) => h3CellSchema.parse(cell)); + const cells = gridDisk(center, radius).map((cell) => + h3CellSchema.parse(cell), + ); + const startingIndexes = [0, 10, 20, 30, 40, 50]; return { generatedAt, - hexes: cells.map((cell, index) => ({ + hexes: cells.map((cell) => ({ cell, - state: index === 0 || index === 4 ? 'infected' : 'open', + state: 'open' as const, + })), + agents: DEVELOPMENT_AGENT_BLUEPRINTS.map((profile, index) => ({ + ...profile, + id: agentIdSchema.parse(profile.id), + currentCell: cells[startingIndexes[index]!]!, })), - agents: [], events: [], }; } diff --git a/playwright.config.ts b/playwright.config.ts index 6939937..f26c8fa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,11 +12,21 @@ export default defineConfig({ baseURL, trace: 'on-first-retry', }, - webServer: { - command: 'pnpm --filter @agentborne/world-lab dev', - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, + webServer: [ + { + command: + 'AGENTBORNE_PROVIDER=scripted pnpm --filter @agentborne/game-api dev', + url: 'http://127.0.0.1:8787/health', + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: + 'NEXT_PUBLIC_GAME_API_BASE_URL=http://127.0.0.1:8787/api/simulation pnpm --filter @agentborne/world-lab dev', + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65c589..d714346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: apps/game-api: dependencies: + '@agentborne/agent-runtime': + specifier: workspace:* + version: link:../../packages/agent-runtime '@agentborne/shared': specifier: workspace:* version: link:../../packages/shared @@ -37,6 +40,9 @@ importers: '@hono/node-server': specifier: 2.1.0 version: 2.1.0(hono@4.13.2) + h3-js: + specifier: 4.5.0 + version: 4.5.0 hono: specifier: 4.13.2 version: 4.13.2 @@ -108,10 +114,16 @@ importers: '@agentborne/shared': specifier: workspace:* version: link:../shared + h3-js: + specifier: 4.5.0 + version: 4.5.0 zod: specifier: 4.4.3 version: 4.4.3 devDependencies: + tsx: + specifier: 4.23.12 + version: 4.23.12 vitest: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.12)) diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index a9840d6..cca3761 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -1,17 +1,97 @@ import { expect, test } from '@playwright/test'; -test('loads the World Lab map shell and reserved development panels', async ({ +test('runs the complete deterministic World Lab browser flow', async ({ page, }) => { + const openRouterRequests: string[] = []; + const browserFailures: string[] = []; + page.on('request', (request) => { + if (request.url().includes('openrouter.ai')) + openRouterRequests.push(request.url()); + }); + page.on('requestfailed', (request) => { + browserFailures.push( + `request failed: ${request.method()} ${request.url()} · ${request.failure()?.errorText ?? 'unknown'}`, + ); + }); + page.on('pageerror', (error) => { + browserFailures.push(`page error: ${error.message}`); + }); + await page.goto('/'); + try { + await expect(page.getByText('Automated-test provider')).toBeVisible({ + timeout: 30_000, + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nBrowser failures:\n${browserFailures.join('\n') || 'none captured'}`, + ); + } + await expect(page.getByRole('heading', { name: 'World Lab' })).toBeVisible(); - await expect(page.getByTestId('world-map')).toBeVisible(); + const worldMap = page.getByTestId('world-map'); + await expect(worldMap).toBeVisible(); + await expect(worldMap).toHaveAttribute('data-overlay-status', 'ready'); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ); + await expect( + page.getByText(/H3 overlay ready · 61\/61 rendered cells · 6 agents/), + ).toBeVisible(); + await expect(page.getByTestId('infected-count')).toHaveText( + '0 rendered infected', + ); + + const markers = page.getByRole('button', { name: /Select agent/ }); + await expect(markers).toHaveCount(6); + for (let index = 0; index < 6; index += 1) { + await expect(markers.nth(index)).toBeVisible(); + } + await page.getByRole('button', { name: 'Select agent Rook' }).click(); + await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Simulation controls' }), + page.getByText('2507bb46-7ae4-45ca-8dda-644c4f85ca14'), ).toBeVisible(); + + await page.getByRole('button', { name: 'Single turn' }).click(); + await expect(page.getByText(/Infection ·/)).toBeVisible(); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ); + await expect(page.getByTestId('infected-count')).toHaveText( + '1 rendered infected', + ); + + await page.getByRole('button', { name: 'Start' }).click(); + await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); + await expect(page.getByText('Turn 2', { exact: true })).toBeVisible({ + timeout: 10_000, + }); + await page.getByRole('button', { name: 'Pause' }).click(); + await expect(page.getByRole('button', { name: 'Start' })).toBeVisible(); + + await page.getByRole('button', { name: 'Reset' }).click(); + await expect(page.getByText('Turn 0')).toBeVisible(); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ); + await expect(page.getByTestId('infected-count')).toHaveText( + '0 rendered infected', + ); + await expect(markers).toHaveCount(6); + for (let index = 0; index < 6; index += 1) { + await expect(markers.nth(index)).toBeVisible(); + } await expect( - page.getByRole('heading', { name: 'Agent inspector' }), + page.getByText('Development world loaded with six agents.'), ).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Event log' })).toBeVisible(); + expect(openRouterRequests).toEqual([]); });