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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ Open the World Lab at <http://localhost:3000>. The Game API binds to <http://127

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.
State is held only in the Game API process. The API captures one active safe experiment with up to 5,000 complete turn records while the browser snapshot remains capped at 120. Minimal, Standard, Full safe, and Custom exports filter that same capture without changing prompts or inference usage. OpenRouter's returned `usage.cost` is shown as known cost; missing cost remains explicitly unknown. Restarting the API loses the experiment. Reset warns before discarding telemetry, creates a new experiment identity, reconstructs the same 61-cell Toledo world, and preserves active personalities.

Export previews report exact serialized UTF-8 bytes and a model-agnostic `ceil(bytes / 4)` approximate AI-input-token estimate. Compact JSON is the default for AI sharing; Pretty JSON remains available for human review, and preview estimates reflect the selected serialization. This is a sharing-budget aid, not tokenizer output or a billing guarantee. Exports exclude fixed prompts, raw provider payloads, credentials, authorization headers, private reasoning, and unbounded diagnostics.

## Opt-in real-provider smoke

Expand All @@ -46,4 +48,6 @@ The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and ren

Social messaging, relationship memory, player mechanics, persistence, autonomous scheduling, and provider configuration UI remain deferred to later roadmap milestones.

When every development cell is infected, World Lab automatically pauses playback and disables Start to avoid accidental provider calls. Reset and export remain available, and Single turn remains an explicitly manual diagnostic action.

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 @@ -29,6 +29,8 @@ Prompt/personality editing, presets, cloning, respawning, reproducible starting

First focused slice: server-owned session personality editing for the six existing agents, five bounded presets, world reset that preserves active personality configuration, and a separate confirmed restore-default action. Persistence across process restarts, cloning, respawning, provider/model configuration, cost visibility, and social mechanics remain deferred to later focused slices or milestones.

Second focused slice: server-owned safe experiment telemetry, actual OpenRouter usage/cost visibility, filtered tiered JSON export, and automatic browser playback pause when all 61 development cells are infected. The active experiment retains 5,000 complete safe records independently of the 120-turn browser snapshot; reset creates a new experiment and process restart still loses all telemetry. Persistence, multiple stored experiments, upload/sharing, provider configuration, and budget enforcement remain deferred.

## PR 4 — Social agents

Range-limited messages, agent inboxes, relationship memories, communication visualization, cooperation, refusal, deception, and betrayal emerging through prompts rather than a large formal rules system.
Expand Down
115 changes: 115 additions & 0 deletions apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from '@agentborne/agent-runtime';
import {
h3CellSchema,
experimentExportPreviewSchema,
experimentExportResponseSchema,
resetSimulationResponseSchema,
restoreDefaultPersonalitiesResponseSchema,
simulationSnapshotSchema,
Expand Down Expand Up @@ -348,6 +350,24 @@ describe('game API simulation boundary', () => {
await expect(restoreConflict.json()).resolves.toMatchObject({
error: { code: 'personality_conflict' },
});
const exportConflict = await app.request(
'/api/simulation/experiment/export/preview',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agents: { mode: 'all' },
turns: { mode: 'entire-retained' },
outcomes: ['accepted'],
actions: ['wait'],
level: 'minimal',
}),
},
);
expect(exportConflict.status).toBe(409);
await expect(exportConflict.json()).resolves.toMatchObject({
error: { code: 'export_conflict' },
});
release({
decision: { requestedAction: { type: 'wait' }, summary: 'Done.' },
metadata: {
Expand All @@ -374,4 +394,99 @@ describe('game API simulation boundary', () => {
},
});
});

it('previews and generates schema-valid retained exports through narrow endpoints', async () => {
const app = createApp({
provider: new ScriptedAgentProvider([
{ requestedAction: { type: 'wait' }, summary: 'Wait.' },
]),
});
await app.request('/api/simulation/turn', { method: 'POST' });
const request = {
agents: { mode: 'all' },
turns: { mode: 'entire-retained' },
outcomes: ['accepted', 'rejected', 'provider-error'],
actions: ['move', 'infect', 'wait'],
level: 'minimal',
};
const previewResponse = await app.request(
'/api/simulation/experiment/export/preview',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
);
expect(previewResponse.status).toBe(200);
const preview = experimentExportPreviewSchema.parse(
await previewResponse.json(),
);
expect(preview).toMatchObject({
matchingRecordCount: 1,
knownCostCredits: 0,
turnsWithUnknownCost: 0,
});
const generatedResponse = await app.request(
'/api/simulation/experiment/export',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
);
const generated = experimentExportResponseSchema.parse(
await generatedResponse.json(),
);
expect(
generated.document.turns.map(({ turnNumber }) => turnNumber),
).toEqual([1]);
expect(JSON.stringify(generated)).not.toMatch(
/authorization|api[_-]?key|rawPrompt|rawBody|chainOfThought|privateReasoning/i,
);
});

it.each([
[{}, 400, 'invalid_export'],
[
{
agents: { mode: 'selected', agentIds: [] },
turns: { mode: 'entire-retained' },
outcomes: ['accepted'],
actions: ['wait'],
level: 'minimal',
},
400,
'invalid_export',
],
[
{
agents: {
mode: 'selected',
agentIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
},
turns: { mode: 'entire-retained' },
outcomes: ['accepted'],
actions: ['wait'],
level: 'minimal',
},
404,
'unknown_agent',
],
])(
'returns typed safe export validation failures',
async (body, status, code) => {
const app = createApp({
provider: new ScriptedAgentProvider([
{ requestedAction: { type: 'wait' }, summary: 'Wait.' },
]),
});
const response = await app.request('/api/simulation/experiment/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.status).toBe(status);
await expect(response.json()).resolves.toMatchObject({ error: { code } });
},
);
});
74 changes: 73 additions & 1 deletion apps/game-api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hono } from 'hono';
import { Hono, type Context } from 'hono';
import { cors } from 'hono/cors';
import {
BrowserTestAgentProvider,
Expand All @@ -8,6 +8,9 @@ import {
} from '@agentborne/agent-runtime';
import {
apiErrorSchema,
experimentExportRequestSchema,
experimentExportPreviewSchema,
experimentExportResponseSchema,
PERSONALITY_MAX_LENGTH,
resetSimulationResponseSchema,
restoreDefaultPersonalitiesResponseSchema,
Expand All @@ -23,6 +26,7 @@ import {
SimulationService,
SimulationValidationError,
} from './simulation-service';
import { ExperimentExportValidationError } from './experiment-export';

export const healthResponseSchema = worldSnapshotSchema
.pick({ generatedAt: true })
Expand Down Expand Up @@ -187,6 +191,56 @@ export function createApp(options: AppOptions = {}) {
}
});

app.post('/api/simulation/experiment/export/preview', async (context) => {
const request = experimentExportRequestSchema.safeParse(
await context.req.json().catch(() => undefined),
);
if (!request.success)
return context.json(
apiErrorSchema.parse({
error: {
code: 'invalid_export',
message: 'The export filters are invalid.',
},
}),
400,
);
try {
return context.json(
experimentExportPreviewSchema.parse(
service.previewExperimentExport(request.data),
),
);
} catch (error) {
return exportErrorResponse(context, error);
}
});

app.post('/api/simulation/experiment/export', async (context) => {
const request = experimentExportRequestSchema.safeParse(
await context.req.json().catch(() => undefined),
);
if (!request.success)
return context.json(
apiErrorSchema.parse({
error: {
code: 'invalid_export',
message: 'The export filters are invalid.',
},
}),
400,
);
try {
return context.json(
experimentExportResponseSchema.parse({
document: service.generateExperimentExport(request.data),
}),
);
} catch (error) {
return exportErrorResponse(context, error);
}
});

app.notFound((context) =>
context.json(
apiErrorSchema.parse({
Expand Down Expand Up @@ -219,3 +273,21 @@ export function createApp(options: AppOptions = {}) {
}

export type GameApi = ReturnType<typeof createApp>;

function exportErrorResponse(context: Context, error: unknown) {
if (error instanceof SimulationConflictError)
return context.json(
apiErrorSchema.parse({
error: { code: 'export_conflict', message: error.message },
}),
409,
);
if (error instanceof ExperimentExportValidationError)
return context.json(
apiErrorSchema.parse({
error: { code: error.code, message: error.message },
}),
error.code === 'unknown_agent' ? 404 : 400,
);
throw error;
}
Loading
Loading