Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
45 changes: 29 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:3000>. Run `pnpm dev:api` in another terminal for the API at <http://localhost:8787>; its current endpoints are `GET /health` and `GET /api/development-world`.
Open the World Lab at <http://localhost:3000>. The Game API binds to <http://127.0.0.1:8787>; 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).
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/game-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
217 changes: 204 additions & 13 deletions apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderDecision> {
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({
Expand Down
Loading
Loading