From 1ba6f0ba1d70f56d76c96ea4d69fd6686e95f9ca Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Fri, 14 Aug 2026 02:35:53 -0400 Subject: [PATCH] feat: add personality editing to World Lab --- ROADMAP.md | 2 + apps/game-api/src/app.test.ts | 156 +++++++++- apps/game-api/src/app.ts | 72 +++++ apps/game-api/src/simulation-service.test.ts | 146 +++++++++ apps/game-api/src/simulation-service.ts | 96 +++++- apps/world-lab/src/app/styles.css | 111 ++++++- .../src/components/personality-presets.ts | 62 ++++ .../src/components/world-lab.test.tsx | 268 ++++++++++++++++- apps/world-lab/src/components/world-lab.tsx | 278 +++++++++++++++++- docs/ARCHITECTURE.md | 11 +- docs/SECURITY.md | 4 + docs/TESTING.md | 6 +- .../0003-session-personality-configuration.md | 22 ++ packages/agent-runtime/src/index.test.ts | 4 + packages/agent-runtime/src/index.ts | 2 +- packages/shared/src/index.test.ts | 96 ++++-- packages/shared/src/index.ts | 45 ++- tests/e2e/world-lab.spec.ts | 61 +++- 18 files changed, 1391 insertions(+), 51 deletions(-) create mode 100644 apps/world-lab/src/components/personality-presets.ts create mode 100644 docs/adr/0003-session-personality-configuration.md diff --git a/ROADMAP.md b/ROADMAP.md index 74022e4..d819d07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,8 @@ Implementation scope: six fixed agent profiles, a 61-cell Toledo development wor Prompt/personality editing, presets, cloning, respawning, reproducible starting worlds, provider/model configuration, and cost visibility. +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. + ## 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. diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index c79b1b2..eeebba6 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -9,8 +9,10 @@ import { import { h3CellSchema, resetSimulationResponseSchema, + restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, + updateAgentPersonalityResponseSchema, } from '@agentborne/shared'; import { createApp } from './app'; @@ -160,12 +162,23 @@ describe('game API simulation boundary', () => { }); }); - it('resets the complete simulation', async () => { + it('resets world progress while preserving personality configuration', async () => { const app = createApp({ provider: new ScriptedAgentProvider([ { requestedAction: { type: 'infect' }, summary: 'Infect.' }, ]), }); + const initial = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + await app.request( + `/api/simulation/agents/${initial.world.agents[0]!.id}/personality`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ personality: 'Preserved through reset.' }), + }, + ); await app.request('/api/simulation/turn', { method: 'POST' }); const response = await app.request('/api/simulation/reset', { method: 'POST', @@ -173,6 +186,124 @@ describe('game API simulation boundary', () => { const reset = resetSimulationResponseSchema.parse(await response.json()); expect(reset.snapshot.turnNumber).toBe(0); expect(reset.snapshot.world.events).toEqual([]); + expect(reset.snapshot.world.agents[0]!.personality).toBe( + 'Preserved through reset.', + ); + }); + + it('updates one agent personality through a runtime-validated safe response', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); + const initial = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + const agent = initial.world.agents[0]!; + const response = await app.request( + `/api/simulation/agents/${agent.id}/personality`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ personality: ' Explore open edges. ' }), + }, + ); + expect(response.status).toBe(200); + const payload = updateAgentPersonalityResponseSchema.parse( + await response.json(), + ); + expect(payload.agent.personality).toBe('Explore open edges.'); + expect(payload.snapshot.world.agents[0]!.personality).toBe( + 'Explore open edges.', + ); + expect(JSON.stringify(payload)).not.toMatch(/api[_-]?key|secret|prompt/i); + }); + + it.each([ + JSON.stringify({ personality: '' }), + JSON.stringify({ personality: 42 }), + '{malformed', + ])('rejects invalid personality request bodies safely', async (body) => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); + const response = await app.request( + '/api/simulation/agents/128f3f38-6b7d-4db7-9e95-751b4ce2681e/personality', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { + code: 'invalid_personality', + message: 'Personality must contain 1 to 600 characters.', + }, + }); + }); + + it('returns typed invalid and unknown agent errors without internal details', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); + for (const [agentId, status, code] of [ + ['not-a-uuid', 400, 'invalid_agent_id'], + ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 404, 'unknown_agent'], + ] as const) { + const response = await app.request( + `/api/simulation/agents/${agentId}/personality`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ personality: 'Valid request.' }), + }, + ); + expect(response.status).toBe(status); + const body = await response.json(); + expect(body).toMatchObject({ error: { code } }); + expect(JSON.stringify(body)).not.toMatch( + /stack|provider|openrouter|secret/i, + ); + } + }); + + it('restores all default personalities without resetting progress', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + }); + const initial = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + const agent = initial.world.agents[0]!; + await app.request(`/api/simulation/agents/${agent.id}/personality`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ personality: 'Custom.' }), + }); + await app.request('/api/simulation/turn', { method: 'POST' }); + + const response = await app.request( + '/api/simulation/personalities/restore-defaults', + { method: 'POST' }, + ); + expect(response.status).toBe(200); + const restored = restoreDefaultPersonalitiesResponseSchema.parse( + await response.json(), + ); + expect(restored.snapshot.turnNumber).toBe(1); + expect(restored.snapshot.world.events).toHaveLength(1); + expect(restored.snapshot.world.agents[0]!.personality).toBe( + agent.personality, + ); }); it('returns typed conflicts for an overlapping turn and reset', async () => { @@ -194,6 +325,29 @@ describe('game API simulation boundary', () => { expect( (await app.request('/api/simulation/reset', { method: 'POST' })).status, ).toBe(409); + const agentId = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ).world.agents[0]!.id; + const editConflict = await app.request( + `/api/simulation/agents/${agentId}/personality`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ personality: 'Blocked.' }), + }, + ); + expect(editConflict.status).toBe(409); + await expect(editConflict.json()).resolves.toMatchObject({ + error: { code: 'personality_conflict' }, + }); + const restoreConflict = await app.request( + '/api/simulation/personalities/restore-defaults', + { method: 'POST' }, + ); + expect(restoreConflict.status).toBe(409); + await expect(restoreConflict.json()).resolves.toMatchObject({ + error: { code: 'personality_conflict' }, + }); release({ decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, metadata: { diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index 7ff8d0c..28b3902 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -8,15 +8,20 @@ import { } from '@agentborne/agent-runtime'; import { apiErrorSchema, + PERSONALITY_MAX_LENGTH, resetSimulationResponseSchema, + restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, + updateAgentPersonalityRequestSchema, + updateAgentPersonalityResponseSchema, worldSnapshotSchema, } from '@agentborne/shared'; import { createDevelopmentWorld } from '@agentborne/world-engine'; import { SimulationConflictError, SimulationService, + SimulationValidationError, } from './simulation-service'; export const healthResponseSchema = worldSnapshotSchema @@ -115,6 +120,73 @@ export function createApp(options: AppOptions = {}) { } }); + app.post('/api/simulation/agents/:agentId/personality', async (context) => { + const request = updateAgentPersonalityRequestSchema.safeParse( + await context.req.json().catch(() => undefined), + ); + if (!request.success) { + return context.json( + apiErrorSchema.parse({ + error: { + code: 'invalid_personality', + message: `Personality must contain 1 to ${PERSONALITY_MAX_LENGTH} characters.`, + }, + }), + 400, + ); + } + try { + const agent = service.updateAgentPersonality( + context.req.param('agentId'), + request.data.personality, + ); + return context.json( + updateAgentPersonalityResponseSchema.parse({ + snapshot: service.getSnapshot(), + agent, + }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'personality_conflict', message: error.message }, + }), + 409, + ); + } + if (error instanceof SimulationValidationError) { + return context.json( + apiErrorSchema.parse({ + error: { code: error.code, message: error.message }, + }), + error.code === 'unknown_agent' ? 404 : 400, + ); + } + throw error; + } + }); + + app.post('/api/simulation/personalities/restore-defaults', (context) => { + try { + return context.json( + restoreDefaultPersonalitiesResponseSchema.parse({ + snapshot: service.restoreDefaultPersonalities(), + }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'personality_conflict', message: error.message }, + }), + 409, + ); + } + throw error; + } + }); + app.notFound((context) => context.json( apiErrorSchema.parse({ diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 1c03839..f136c78 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -6,14 +6,17 @@ import { type ProviderDecision, } from '@agentborne/agent-runtime'; import { + PERSONALITY_MAX_LENGTH, h3CellSchema, type AgentObservation, type AgentTurnRecord, type WorldEvent, } from '@agentborne/shared'; +import { DEVELOPMENT_AGENT_BLUEPRINTS } from '@agentborne/world-engine'; import { SimulationConflictError, SimulationService, + SimulationValidationError, } from './simulation-service'; const now = () => '2026-08-13T12:00:01.000Z'; @@ -117,6 +120,140 @@ describe('SimulationService', () => { expect(second.observation.recentEvents[0]?.type).toBe('hex-infected'); }); + it('updates an existing agent and uses the trimmed personality on its next turn', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Use the edit.' }, + ]), + ); + const agent = simulation.getSnapshot().world.agents[0]!; + const updated = simulation.updateAgentPersonality( + agent.id, + ' Prioritize adjacent open cells. ', + ); + expect(updated).toMatchObject({ + id: agent.id, + personality: 'Prioritize adjacent open cells.', + }); + expect((await simulation.executeNextTurn()).observation.personality).toBe( + 'Prioritize adjacent open cells.', + ); + }); + + it('rejects unknown agents and invalid personalities without mutation, then recovers', () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + ); + const before = simulation.getSnapshot(); + expect(() => + simulation.updateAgentPersonality( + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'Valid personality.', + ), + ).toThrow(SimulationValidationError); + expect(() => + simulation.updateAgentPersonality(before.world.agents[0]!.id, ' '), + ).toThrow(SimulationValidationError); + expect(() => + simulation.updateAgentPersonality( + before.world.agents[0]!.id, + 'x'.repeat(PERSONALITY_MAX_LENGTH + 1), + ), + ).toThrow(SimulationValidationError); + expect(simulation.getSnapshot()).toEqual(before); + + simulation.updateAgentPersonality( + before.world.agents[0]!.id, + 'Recovered personality.', + ); + expect(simulation.getSnapshot().world.agents[0]!.personality).toBe( + 'Recovered personality.', + ); + }); + + it('edits personality without changing world progress or historical observations', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + await simulation.executeNextTurn(); + const before = simulation.getSnapshot(); + const agent = before.world.agents[0]!; + simulation.updateAgentPersonality(agent.id, 'A new active personality.'); + const after = simulation.getSnapshot(); + + expect(after.world.hexes).toEqual(before.world.hexes); + expect(after.world.events).toEqual(before.world.events); + expect(after.world.agents.map(({ currentCell }) => currentCell)).toEqual( + before.world.agents.map(({ currentCell }) => currentCell), + ); + expect( + after.world.agents.map(({ id, name, color }) => ({ id, name, color })), + ).toEqual( + before.world.agents.map(({ id, name, color }) => ({ id, name, color })), + ); + expect(after.turns).toEqual(before.turns); + expect(after.turns[0]!.observation.personality).toBe(agent.personality); + expect(after.turnNumber).toBe(before.turnNumber); + expect(after.nextAgentId).toBe(before.nextAgentId); + }); + + it('reset preserves active personality edits while restoring deterministic progress', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + const initial = simulation.getSnapshot(); + for (const agent of initial.world.agents) { + simulation.updateAgentPersonality( + agent.id, + `Preserve ${agent.name}'s edit.`, + ); + } + await simulation.executeNextTurn(); + const reset = simulation.reset(); + + expect(reset).toMatchObject({ turnNumber: 0, turns: [] }); + expect(reset.world.events).toEqual([]); + expect(reset.world.hexes).toEqual(initial.world.hexes); + expect(reset.world.agents.map(({ currentCell }) => currentCell)).toEqual( + initial.world.agents.map(({ currentCell }) => currentCell), + ); + expect(reset.world.agents.map(({ personality }) => personality)).toEqual( + initial.world.agents.map(({ name }) => `Preserve ${name}'s edit.`), + ); + }); + + it('restores all six defaults without resetting current world progress', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + for (const agent of simulation.getSnapshot().world.agents) { + simulation.updateAgentPersonality(agent.id, `Custom ${agent.name}.`); + } + await simulation.executeNextTurn(); + const before = simulation.getSnapshot(); + const restored = simulation.restoreDefaultPersonalities(); + + expect(restored.world.agents.map(({ personality }) => personality)).toEqual( + DEVELOPMENT_AGENT_BLUEPRINTS.map(({ personality }) => personality), + ); + expect(restored.world.hexes).toEqual(before.world.hexes); + expect(restored.world.events).toEqual(before.world.events); + expect(restored.turns).toEqual(before.turns); + expect(restored.turnNumber).toBe(before.turnNumber); + expect(restored.nextAgentId).toBe(before.nextAgentId); + expect(restored.world.agents.map(({ currentCell }) => currentCell)).toEqual( + before.world.agents.map(({ currentCell }) => currentCell), + ); + }); + it('records accepted and rejected actions without mutating on rejection', async () => { const initial = service( new ScriptedAgentProvider([ @@ -332,6 +469,15 @@ describe('SimulationService', () => { SimulationConflictError, ); expect(() => simulation.reset()).toThrow(SimulationConflictError); + expect(() => + simulation.updateAgentPersonality( + simulation.getSnapshot().world.agents[0]!.id, + 'Blocked edit.', + ), + ).toThrow(SimulationConflictError); + expect(() => simulation.restoreDefaultPersonalities()).toThrow( + SimulationConflictError, + ); release({ decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, metadata: { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 4472d78..0f55ce1 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -5,10 +5,14 @@ import { type ProviderDecision, } from '@agentborne/agent-runtime'; import { + agentIdSchema, agentObservationSchema, agentTurnRecordSchema, h3CellSchema, + PERSONALITY_MAX_LENGTH, + personalitySchema, simulationSnapshotSchema, + type Agent, type AgentId, type AgentObservation, type AgentTurnRecord, @@ -21,6 +25,7 @@ import { import { applyRequestedAction, createDevelopmentWorld, + DEVELOPMENT_AGENT_BLUEPRINTS, toWorldState, type WorldState, } from '@agentborne/world-engine'; @@ -36,6 +41,19 @@ export class SimulationConflictError extends Error { } } +export type SimulationValidationCode = + 'invalid_agent_id' | 'unknown_agent' | 'invalid_personality'; + +export class SimulationValidationError extends Error { + constructor( + readonly code: SimulationValidationCode, + message: string, + ) { + super(message); + this.name = 'SimulationValidationError'; + } +} + export interface SimulationServiceOptions { provider: AgentProvider; now?: () => string; @@ -96,9 +114,24 @@ export class SimulationService { ); } this.#status = 'resetting'; - this.#state = toWorldState( + const activePersonalities = new Map( + [...this.#state.agents].map(([id, agent]) => [id, agent.personality]), + ); + const resetState = toWorldState( createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), ); + this.#state = { + ...resetState, + agents: new Map( + [...resetState.agents].map(([id, agent]) => [ + id, + { + ...agent, + personality: activePersonalities.get(id) ?? agent.personality, + }, + ]), + ), + }; this.#turns = []; this.#completedTurnCount = 0; this.#cursor = 0; @@ -107,6 +140,67 @@ export class SimulationService { return this.getSnapshot(); } + updateAgentPersonality( + agentIdInput: unknown, + personalityInput: unknown, + ): Agent { + if (this.#busy) { + throw new SimulationConflictError( + 'Personality changes are unavailable while a model turn is in progress.', + ); + } + const agentIdResult = agentIdSchema.safeParse(agentIdInput); + if (!agentIdResult.success) { + throw new SimulationValidationError( + 'invalid_agent_id', + 'The agent ID is invalid.', + ); + } + const personalityResult = personalitySchema.safeParse(personalityInput); + if (!personalityResult.success) { + throw new SimulationValidationError( + 'invalid_personality', + `Personality must contain 1 to ${PERSONALITY_MAX_LENGTH} characters.`, + ); + } + const agent = this.#state.agents.get(agentIdResult.data); + if (!agent) { + throw new SimulationValidationError( + 'unknown_agent', + 'The requested agent does not exist.', + ); + } + const updated = { ...agent, personality: personalityResult.data }; + const agents = new Map(this.#state.agents); + agents.set(agent.id, updated); + this.#state = { ...this.#state, agents }; + return updated; + } + + restoreDefaultPersonalities(): SimulationSnapshot { + if (this.#busy) { + throw new SimulationConflictError( + 'Personality changes are unavailable while a model turn is in progress.', + ); + } + const defaults = new Map( + DEVELOPMENT_AGENT_BLUEPRINTS.map(({ id, personality }) => [ + agentIdSchema.parse(id), + personality, + ]), + ); + this.#state = { + ...this.#state, + agents: new Map( + [...this.#state.agents].map(([id, agent]) => [ + id, + { ...agent, personality: defaults.get(id) ?? agent.personality }, + ]), + ), + }; + return this.getSnapshot(); + } + async executeNextTurn(): Promise { if (this.#busy) { throw new SimulationConflictError('A model turn is already in progress.'); diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 54c1eec..bb311af 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -22,7 +22,8 @@ body { } button, input, -select { +select, +textarea { font: inherit; } @@ -264,6 +265,19 @@ dd { color: #71807b; cursor: not-allowed; } +.secondary-action { + width: 100%; + padding: 7px 10px; + border: 1px solid #394642; + border-radius: 5px; + color: #bdcbc6; + background: transparent; + cursor: pointer; +} +.secondary-action:disabled { + color: #71807b; + cursor: not-allowed; +} .next-agent { color: #9aaba5; font-size: 0.8rem; @@ -296,6 +310,11 @@ dd { background: #3c2421; color: #ffc2b8; } +.callout.success { + border: 1px solid #35684f; + background: #203d31; + color: #a9e7c4; +} .agent-inspector h2 { display: flex; align-items: center; @@ -310,6 +329,89 @@ dd { letter-spacing: 0.06em; text-transform: uppercase; } +.personality-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 17px; +} +.personality-heading h3 { + margin: 0; +} +.personality-heading button, +.editor-actions button { + padding: 5px 9px; + border: 1px solid #465752; + border-radius: 5px; + color: #dbe7e3; + background: #202927; + cursor: pointer; +} +.personality-heading button:disabled, +.editor-actions button:disabled { + color: #71807b; + cursor: not-allowed; +} +.active-personality { + margin-top: 8px; +} +.active-personality p { + color: #d1dcd8; + font-size: 0.8rem; + line-height: 1.55; +} +.active-personality span { + display: inline-block; + margin-top: 7px; + padding: 2px 7px; + border: 1px solid #3c4b47; + border-radius: 999px; + color: #91a59f; + font-size: 0.68rem; +} +.personality-editor { + display: grid; + gap: 10px; + margin-top: 10px; +} +.personality-editor label { + display: grid; + gap: 5px; + color: #aab9b4; + font-size: 0.74rem; +} +.personality-editor select, +.personality-editor textarea { + width: 100%; + padding: 8px; + border: 1px solid #43524e; + border-radius: 5px; + background: #111816; + color: #e0e9e6; +} +.personality-editor textarea { + resize: vertical; + line-height: 1.45; +} +.editor-meta { + display: flex; + justify-content: space-between; + gap: 12px; + color: #81928c; + font-size: 0.68rem; +} +.editor-meta span:last-child { + flex: 0 0 auto; +} +.editor-actions { + display: flex; + gap: 7px; +} +.inline-error { + color: #ffafa3 !important; + font-size: 0.74rem !important; +} .agent-inspector > p, .turn-detail p, .turn-detail details, @@ -352,6 +454,13 @@ dd { margin-top: 6px; overflow-wrap: anywhere; } +.observation-note { + color: #8fa19b !important; + font-style: italic; +} +.observation-difference { + color: #ffd58a !important; +} .compact-history { margin: 0; padding-left: 18px; diff --git a/apps/world-lab/src/components/personality-presets.ts b/apps/world-lab/src/components/personality-presets.ts new file mode 100644 index 0000000..103eafb --- /dev/null +++ b/apps/world-lab/src/components/personality-presets.ts @@ -0,0 +1,62 @@ +import { personalitySchema } from '@agentborne/shared'; + +export const PERSONALITY_PRESET_IDS = [ + 'aggressive-infector', + 'explorer', + 'territorial', + 'opportunist', + 'agent-seeking', +] as const; + +export type PersonalityPresetId = (typeof PERSONALITY_PRESET_IDS)[number]; + +export interface PersonalityPreset { + readonly id: PersonalityPresetId; + readonly name: string; + readonly personality: string; +} + +const personalityPresetDefinitions = [ + { + id: 'aggressive-infector', + name: 'Aggressive infector', + personality: + 'Prioritize infecting the current cell whenever it is open. When it is already infected, move decisively to an adjacent open cell; wait only when no useful adjacent move is available.', + }, + { + id: 'explorer', + name: 'Explorer', + personality: + 'Favor movement and variety. Choose adjacent open cells when possible, avoid repeatedly lingering near the same activity, and infect only when movement offers little new territory to observe.', + }, + { + id: 'territorial', + name: 'Territorial', + personality: + 'Build a compact infected area. Infect the current cell when it is open; otherwise prefer adjacent cells near visible infected cells and avoid drifting away from the local cluster.', + }, + { + id: 'opportunist', + name: 'Opportunist', + personality: + 'Exploit the clearest immediate opportunity in each observation. Infect an open current cell, move to a useful adjacent open cell when already infected, and wait when neither improves the situation.', + }, + { + id: 'agent-seeking', + name: 'Agent-seeking', + personality: + 'Seek visible nearby agents. Prefer an adjacent move that brings you closer to an observed agent, infect the current cell when useful, and wait only when no adjacent move improves proximity.', + }, +] as const satisfies readonly PersonalityPreset[]; + +export const PERSONALITY_PRESETS: readonly PersonalityPreset[] = + personalityPresetDefinitions.map((preset) => ({ + ...preset, + personality: personalitySchema.parse(preset.personality), + })); + +export function matchingPersonalityPreset(personality: string) { + return PERSONALITY_PRESETS.find( + (preset) => preset.personality === personality, + ); +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 09f7b87..e7d137d 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -8,6 +8,7 @@ import { } from '@agentborne/shared'; import { createDevelopmentWorld } from '@agentborne/world-engine'; import { WorldLab } from './world-lab'; +import { PERSONALITY_PRESETS } from './personality-presets'; const mapLibreMock = vi.hoisted(() => ({ renderMode: 'complete' as 'complete' | 'incomplete', @@ -235,6 +236,22 @@ function jsonResponse(value: unknown) { return Promise.resolve(new Response(JSON.stringify(value), { status: 200 })); } +function withPersonality( + snapshot: SimulationSnapshot, + agentId: string, + personality: string, +): SimulationSnapshot { + return simulationSnapshotSchema.parse({ + ...snapshot, + world: { + ...snapshot.world, + agents: snapshot.world.agents.map((agent) => + agent.id === agentId ? { ...agent, personality } : agent, + ), + }, + }); +} + beforeEach(() => { mapLibreMock.renderMode = 'complete'; mapLibreMock.rejectSource = false; @@ -276,7 +293,10 @@ describe('WorldLab', () => { ).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.getByRole('button', { name: 'Reset world' })).toBeEnabled(); + expect( + screen.getByRole('button', { name: 'Restore default personalities' }), + ).toBeEnabled(); expect(screen.getByLabelText('Playback speed')).toBeInTheDocument(); expect( screen.getAllByRole('button', { name: /Select agent/ }), @@ -451,7 +471,7 @@ describe('WorldLab', () => { expect( await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Reset' })); + await user.click(screen.getByRole('button', { name: 'Reset world' })); await waitFor(() => expect( screen.getByText('Development world loaded with six agents.'), @@ -497,4 +517,248 @@ describe('WorldLab', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Single turn' })).toBeDisabled(); }); + + it('enters and cancels explicit personality editing without a request', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Edit' })); + const textarea = screen.getByRole('textbox', { + name: 'Personality directive', + }); + expect(textarea).toHaveValue(world.agents[0]!.personality); + await user.type(textarea, ' unsaved'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText(world.agents[0]!.personality)).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('applies a trimmed custom personality only after Apply', async () => { + const custom = 'Choose open adjacent cells before waiting.'; + const changed = withPersonality(initial, world.agents[0]!.id, custom); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(initial)) + .mockImplementationOnce(() => + jsonResponse({ snapshot: changed, agent: changed.world.agents[0] }), + ), + ); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Edit' })); + const textarea = screen.getByRole('textbox', { + name: 'Personality directive', + }); + await user.clear(textarea); + await user.type(textarea, ` ${custom} `); + expect(fetch).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(await screen.findByText(custom)).toBeInTheDocument(); + expect(screen.getByText('Custom')).toBeInTheDocument(); + expect(fetch).toHaveBeenLastCalledWith( + `${apiBaseForTest()}/agents/${world.agents[0]!.id}/personality`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ personality: custom }), + }), + ); + }); + + it.each(PERSONALITY_PRESETS)( + 'selects and applies the $name preset explicitly', + async (preset) => { + const changed = withPersonality( + initial, + world.agents[0]!.id, + preset.personality, + ); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(initial)) + .mockImplementationOnce(() => + jsonResponse({ + snapshot: changed, + agent: changed.world.agents[0], + }), + ), + ); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Edit' })); + await user.selectOptions( + screen.getByLabelText('Personality preset'), + preset.id, + ); + expect( + screen.getByRole('textbox', { name: 'Personality directive' }), + ).toHaveValue(preset.personality); + expect(fetch).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(await screen.findByText(preset.personality)).toBeInTheDocument(); + expect(screen.getByText(preset.name)).toBeInTheDocument(); + }, + ); + + it('shows character count, empty validation, and Custom preset state', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Edit' })); + const textarea = screen.getByRole('textbox', { + name: 'Personality directive', + }); + expect( + screen.getByText(`${world.agents[0]!.personality.length}/600`), + ).toBeInTheDocument(); + expect(textarea).toHaveAttribute('maxlength', '600'); + expect(screen.getByLabelText('Personality preset')).toHaveValue('custom'); + await user.clear(textarea); + expect(screen.getByText('0/600')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Enter a personality between 1 and 600 characters.', + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('disables personality mutations during playback and pending requests', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Edit' })); + await user.click(screen.getByRole('button', { name: 'Start' })); + expect( + screen.getByRole('textbox', { name: 'Personality directive' }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Restore default personalities' }), + ).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Pause' })); + + let resolveUpdate!: (response: Response) => void; + vi.mocked(fetch).mockImplementationOnce( + () => new Promise((resolve) => (resolveUpdate = resolve)), + ); + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(screen.getByRole('button', { name: 'Applying…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reset world' })).toBeDisabled(); + resolveUpdate( + new Response( + JSON.stringify({ snapshot: initial, agent: initial.world.agents[0] }), + { status: 200 }, + ), + ); + await screen.findByRole('button', { name: 'Edit' }); + }); + + it('keeps an edited personality through world reset', async () => { + const edited = withPersonality( + afterInfection(), + world.agents[0]!.id, + 'Persistent lab edit.', + ); + const resetWithEdit = withPersonality( + initial, + world.agents[0]!.id, + 'Persistent lab edit.', + ); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(edited)) + .mockImplementationOnce(() => + jsonResponse({ snapshot: resetWithEdit }), + ), + ); + const user = userEvent.setup(); + render(); + await user.click( + await screen.findByRole('button', { name: 'Reset world' }), + ); + expect(await screen.findByText('Persistent lab edit.')).toBeInTheDocument(); + expect(screen.getByText('Turn 0')).toBeInTheDocument(); + }); + + it('confirms restoring defaults and preserves current world progress', async () => { + const progressed = withPersonality( + afterInfection(), + world.agents[0]!.id, + 'Temporary edit.', + ); + const restored = simulationSnapshotSchema.parse({ + ...progressed, + world: { + ...progressed.world, + agents: progressed.world.agents.map((agent, index) => ({ + ...agent, + personality: world.agents[index]!.personality, + })), + }, + }); + const confirm = vi + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + vi.stubGlobal('confirm', confirm); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementationOnce(() => jsonResponse(progressed)) + .mockImplementationOnce(() => jsonResponse({ snapshot: restored })), + ); + const user = userEvent.setup(); + render(); + const restore = await screen.findByRole('button', { + name: 'Restore default personalities', + }); + await user.click(restore); + expect(fetch).toHaveBeenCalledTimes(1); + await user.click(restore); + expect( + await screen.findByRole('group', { + name: 'Active personality configuration', + }), + ).toHaveTextContent(world.agents[0]!.personality); + expect(screen.getByText('Turn 1')).toBeInTheDocument(); + expect(screen.getByTestId('infected-count')).toHaveTextContent( + '1 rendered infected', + ); + expect(confirm).toHaveBeenCalledTimes(2); + }); + + it('distinguishes an active edit from the immutable latest observation', async () => { + const changed = withPersonality( + afterInfection(), + world.agents[0]!.id, + 'New active personality.', + ); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(changed)), + ); + render(); + expect( + await screen.findByText('New active personality.'), + ).toBeInTheDocument(); + expect( + screen.getByText(world.agents[0]!.personality, { exact: true }), + ).toBeInTheDocument(); + expect( + screen.getByText(/Immutable input supplied for turn 1/), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'The active personality has changed since this observation.', + ), + ).toBeInTheDocument(); + }); }); + +function apiBaseForTest() { + return process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; +} diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index faff557..2b4e9a2 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -2,14 +2,23 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + PERSONALITY_MAX_LENGTH, + personalitySchema, resetSimulationResponseSchema, + restoreDefaultPersonalitiesResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, + updateAgentPersonalityRequestSchema, + updateAgentPersonalityResponseSchema, type AgentId, type AgentTurnRecord, type H3Cell, type SimulationSnapshot, } from '@agentborne/shared'; +import { + matchingPersonalityPreset, + PERSONALITY_PRESETS, +} from './personality-presets'; import { WorldMap } from './world-map'; const latitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LATITUDE ?? 41.6528); @@ -25,6 +34,10 @@ export function WorldLab() { const [running, setRunning] = useState(false); const [inFlight, setInFlight] = useState(false); const [resetting, setResetting] = useState(false); + const [personalityPending, setPersonalityPending] = useState(false); + const [personalityNotice, setPersonalityNotice] = useState( + null, + ); const [speed, setSpeed] = useState(1_000); const [uiError, setUiError] = useState(null); const inFlightRef = useRef(false); @@ -108,6 +121,89 @@ export function WorldLab() { } }; + const updatePersonality = async ( + agentId: AgentId, + personality: string, + ): Promise => { + const request = updateAgentPersonalityRequestSchema.safeParse({ + personality, + }); + if (!request.success) return false; + setPersonalityPending(true); + setPersonalityNotice(null); + setUiError(null); + try { + const response = await fetch(`${apiBase}/agents/${agentId}/personality`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request.data), + }); + if (response.status === 409) { + setUiError( + 'Personality changes are unavailable until the current turn completes.', + ); + return false; + } + if (!response.ok) { + setUiError('The personality was rejected safely by the Game API.'); + return false; + } + const payload = updateAgentPersonalityResponseSchema.parse( + await response.json(), + ); + applySnapshot(payload.snapshot); + setPersonalityNotice(`${payload.agent.name}'s personality was updated.`); + return true; + } catch { + setUiError( + 'Personality update failed safely. The existing personality was left intact.', + ); + return false; + } finally { + setPersonalityPending(false); + } + }; + + const restoreDefaultPersonalities = async () => { + if ( + !window.confirm( + 'Restore the original personalities for all six agents? World progress will be preserved.', + ) + ) + return; + setPersonalityPending(true); + setPersonalityNotice(null); + setUiError(null); + try { + const response = await fetch( + `${apiBase}/personalities/restore-defaults`, + { + method: 'POST', + }, + ); + if (response.status === 409) { + setUiError( + 'Default personalities cannot be restored until the current turn completes.', + ); + return; + } + if (!response.ok) throw new Error('restore personalities failed'); + const payload = restoreDefaultPersonalitiesResponseSchema.parse( + await response.json(), + ); + applySnapshot(payload.snapshot); + setPersonalityNotice( + 'Default personalities restored. World progress was preserved.', + ); + } catch { + setUiError( + 'Restoring default personalities failed safely. Existing configuration was left intact.', + ); + } finally { + setPersonalityPending(false); + } + }; + if (!snapshot) { return (
@@ -139,6 +235,12 @@ export function WorldLab() { const nextAgent = snapshot.world.agents.find( ({ id }) => id === snapshot.nextAgentId, ); + const personalityControlsDisabled = + running || + inFlight || + resetting || + personalityPending || + snapshot.activeAgentId !== null; return (
@@ -193,7 +295,11 @@ export function WorldLab() { ) : ( )} + +