From 98f89b08f50520155d6e5b59d2c99efd3be2d9b7 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sat, 15 Aug 2026 09:57:02 -0400 Subject: [PATCH] feat: polish World Lab command bar and theme --- README.md | 6 +- ROADMAP.md | 2 + apps/world-lab/src/app/styles.css | 505 +++++++++++- apps/world-lab/src/components/map-config.ts | 7 + .../src/components/model-options.test.ts | 43 + .../world-lab/src/components/model-options.ts | 39 + .../world-lab/src/components/ui-color.test.ts | 28 + apps/world-lab/src/components/ui-color.ts | 20 + .../src/components/world-lab.test.tsx | 117 ++- apps/world-lab/src/components/world-lab.tsx | 761 +++++++++++------- .../src/components/world-map-config.test.ts | 14 + apps/world-lab/src/components/world-map.tsx | 61 +- docs/ARCHITECTURE.md | 4 + docs/TESTING.md | 2 + tests/e2e/world-lab.spec.ts | 17 +- 15 files changed, 1286 insertions(+), 340 deletions(-) create mode 100644 apps/world-lab/src/components/map-config.ts create mode 100644 apps/world-lab/src/components/model-options.test.ts create mode 100644 apps/world-lab/src/components/model-options.ts create mode 100644 apps/world-lab/src/components/ui-color.test.ts create mode 100644 apps/world-lab/src/components/ui-color.ts create mode 100644 apps/world-lab/src/components/world-map-config.test.ts diff --git a/README.md b/README.md index 2ce7d95..93ff2b2 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,15 @@ The command reads only `OPENROUTER_API_KEY` from the repository-root `.env`; its ## Development map source -The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. 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. +The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a deterministic radius-six disk of exactly 127 cells with eight fixed perimeter starts. MapLibre uses CARTO Dark Matter's tokenless raster tiles with `© OpenStreetMap contributors © CARTO` attribution. Public deployments remain responsible for confirming provider usage requirements for their traffic. Alliance leadership, merging, custom metadata, combat systems, relationship scores, group chat, persistent memory, player mechanics, persistence, and autonomous scheduling remain deferred. 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. -World Lab also provides a browser-owned **Run to turn 200** control. It continues the existing sequential loop only for the remaining turns, displays progress, and pauses immediately after total completed turn 200 unless full infection stops it first. +World Lab provides browser-owned absolute run targets of **25, 50, 100, 200, 500, and 1,000**. The session-selected target defaults to 200, past/current targets are unavailable, and a bounded run pauses at the authoritative completed-turn target unless cancellation, failure, or full infection stops it first. + +The compact command navbar keeps execution controls and current known cost in one persistent row and moves detailed experiment telemetry, playback speed, and infrequent actions into accessible hover/focus popovers. Its control slots remain stable while async state changes. Explicit agent-marker selection drives the inspector, while explicit H3 selection opens a dismissible map-local details card. Blackberry/teal/mint/celadon/vanilla semantic tokens define the dark application chrome without replacing domain-owned agent and alliance colors. The agent roster defaults to browser-local **Follow turn** behavior: the inspector follows the active request, or the next scheduled agent while paused. Selecting an agent manually disables following without hiding the roster's textual Acting/Next marker; the preference remains in that browser and is never exported. Public world chat and the event log are newest-first bounded feeds, and the shared Model and Export dialogs keep their headers/actions fixed while their bodies scroll within the viewport. diff --git a/ROADMAP.md b/ROADMAP.md index 637e1fe..a52b207 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,6 +51,8 @@ Seventh focused slice: polish the production World Lab with newest-first bounded Eighth focused slice: add versioned objective/personality/strategy prompt layers, reproducible seeded behavior assignment, engine-derived diplomacy affordances, specific safe validation detail codes, a cohesive Agent Controller, and behavior-attributed safe telemetry. Behavior locks after the experiment begins; arbitrary prompts and mid-experiment reassignment remain deferred. +Ninth focused slice: consolidate World Lab controls into one responsive command navbar, add browser-session absolute run targets through turn 1,000, make export generation explicitly artifact-based, unify model-option presentation, synchronize effective alliance colors across activity surfaces, and adopt a tokenless dark basemap plus semantic dark chrome. Simulation rules, model decisions, retry behavior, telemetry meaning, and export contents remain unchanged. + ## PR 5 — Goals and memory Persistent short- and long-term objectives, compact memories, plan revision, summaries, and longer simulation runs. diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index adeb2b2..c54472b 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -1,4 +1,21 @@ :root { + --blackberry-cream: #592941; + --jungle-teal: #498467; + --mint-leaf: #52b788; + --celadon: #b2d3a8; + --vanilla-custard: #ede5a6; + --background: color-mix(in srgb, var(--blackberry-cream) 12%, #070d0b); + --surface: color-mix(in srgb, var(--jungle-teal) 12%, #0b1210); + --surface-raised: color-mix(in srgb, var(--jungle-teal) 18%, #101815); + --surface-hover: color-mix(in srgb, var(--jungle-teal) 28%, #111916); + --line: color-mix(in srgb, var(--jungle-teal) 58%, #17221e); + --text: var(--vanilla-custard); + --text-muted: color-mix(in srgb, var(--celadon) 76%, #83948e); + --accent: var(--mint-leaf); + --accent-strong: color-mix(in srgb, var(--mint-leaf) 76%, #173c2c); + --focus: var(--mint-leaf); + --danger: #f28b82; + --warning: #f2c66d; color-scheme: dark; font-family: Inter, @@ -8,8 +25,8 @@ BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: #111716; - color: #e6ece8; + background: var(--background); + color: var(--text); } * { @@ -21,16 +38,16 @@ body { margin: 0; min-width: 320px; overflow: hidden; - background: #111716; + background: var(--background); } .world-lab-shell { height: 100dvh; overflow: hidden; display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) clamp(160px, 22dvh, 210px); + grid-template-rows: auto auto minmax(0, 1fr) clamp(160px, 22dvh, 210px); } .world-lab-shell.chat-collapsed { - grid-template-rows: auto auto auto minmax(0, 1fr) 54px; + grid-template-rows: auto auto minmax(0, 1fr) 54px; } button, input, @@ -773,10 +790,6 @@ h2 { border: 1px solid #34433f; border-radius: 6px; } -.selected-panel h2 { - font-size: 1.1rem; - margin-bottom: 10px; -} .territory-panel h2 { margin-bottom: 12px; } @@ -1306,3 +1319,477 @@ dd { display: none; } } + +/* Compact World Lab command chrome */ +.command-navbar { + position: relative; + z-index: 20; + min-width: 0; + min-height: 60px; + padding: 7px clamp(10px, 1.25vw, 20px); + display: grid; + grid-template-columns: auto auto minmax(330px, 1fr) minmax(180px, 310px) auto; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 94%, var(--blackberry-cream)); + box-shadow: 0 5px 20px #0006; +} +.command-brand { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.project-mark { + width: 32px; + height: 32px; + display: grid; + place-items: center; + flex: none; + border: 1px solid var(--jungle-teal); + border-radius: 9px; + background: color-mix(in srgb, var(--blackberry-cream) 55%, var(--surface)); + color: var(--vanilla-custard); + font-size: 0.68rem; + font-weight: 800; +} +.command-navbar h1 { + color: var(--text); + font-size: 1rem; + white-space: nowrap; +} +.command-navbar .eyebrow { + color: var(--text-muted); + font-size: 0.58rem; +} +.status-popover, +.overflow-menu { + position: relative; +} +.status-popover > button, +.overflow-menu summary { + list-style: none; + cursor: pointer; + border-radius: 6px; +} +.overflow-menu summary::-webkit-details-marker { + display: none; +} +.status-popover > button { + min-height: 38px; + padding: 7px 9px; + display: flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + background: var(--surface-raised); + color: var(--text); + white-space: nowrap; + font-size: 0.78rem; +} +.status-popover .command-popover { + display: none; +} +.status-popover:hover .command-popover, +.status-popover:focus-within .command-popover { + display: block; +} +.navbar-cost { + margin-left: 4px; + padding-left: 8px; + border-left: 1px solid var(--line); + color: var(--celadon); + font-size: 0.7rem; + font-weight: 650; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 18%, transparent); +} +.status-dot.running { + background: var(--mint-leaf); +} +.status-dot.waiting-for-model, +.status-dot.resetting, +.status-dot.reconciling-request { + background: #7dd3fc; + animation: pulse 1s ease-in-out infinite alternate; +} +.status-dot.provider-error, +.status-dot.configuration-error { + background: var(--danger); +} +.command-popover { + position: absolute; + z-index: 100; + top: calc(100% + 8px); + min-width: 290px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface); + color: var(--text); + box-shadow: 0 16px 44px #000c; +} +.experiment-details { + left: 0; +} +.experiment-details h2 { + margin-bottom: 10px; +} +.experiment-details .usage-meter { + max-height: 160px; + overflow: auto; +} +.command-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 570px; +} +.command-navbar button, +.command-navbar select, +.command-navbar summary { + border-color: var(--line); + background: var(--surface-raised); + color: var(--text); +} +.command-navbar button { + min-height: 36px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} +.command-navbar button:hover:not(:disabled), +.command-navbar summary:hover { + background: var(--surface-hover); + border-color: var(--mint-leaf); +} +.command-navbar button:disabled, +button:disabled, +select:disabled, +input:disabled, +textarea:disabled { + cursor: not-allowed !important; + opacity: 0.48; +} +button:not(:disabled), +select:not(:disabled), +input[type='checkbox']:not(:disabled), +summary { + cursor: pointer; +} +[aria-busy='true'], +[aria-busy='true']:disabled { + cursor: progress !important; +} +button:focus-visible, +select:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} +.icon-button { + width: 38px; + padding: 7px !important; + display: inline-grid; + place-items: center; + flex: none; +} +.primary-command { + border-color: var(--accent-strong) !important; + background: var(--accent-strong) !important; + color: #07130e !important; +} +.destructive-command:hover:not(:disabled) { + border-color: var(--danger) !important; + color: #ffd9d4; +} +.command-icon { + width: 19px; + height: 19px; + fill: currentColor; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.run-target-control { + min-height: 36px; + display: flex; + align-items: center; + gap: 2px; + padding-left: 7px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-raised); +} +.run-target-control .command-icon { + width: 16px; +} +.run-target-control select { + min-height: 34px; + padding: 4px 5px; + border: 0; +} +.run-command, +.cancel-run-command { + min-width: 84px; +} +.cancel-run-command { + color: #ffd9d4 !important; + border-color: color-mix(in srgb, var(--danger) 65%, var(--line)) !important; +} +.spinner { + width: 15px; + height: 15px; + display: inline-block; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + vertical-align: -2px; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.model-console { + min-width: 0; +} +.model-console > .agent-setup-trigger { + width: 100%; + min-width: 0; + display: grid; + gap: 1px; + padding: 5px 9px; + text-align: left; + overflow: hidden; +} +.agent-setup-trigger .setup-label, +.agent-setup-trigger .setup-model { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-setup-trigger .setup-label { + color: var(--text); + font-size: 0.72rem; + font-weight: 700; +} +.agent-setup-trigger .setup-model { + color: var(--text-muted); + font-size: 0.66rem; +} +.world-chat-feed strong { + text-shadow: + 0 1px 2px #000, + 0 0 4px #000; +} +.cancel-request-slot.inactive, +.failed-turn-controls.inactive { + visibility: hidden; +} +.overflow-menu summary { + width: 38px; + height: 36px; + display: grid; + place-items: center; + border: 1px solid var(--line); +} +.overflow-content { + right: 0; + display: grid; + gap: 10px; +} +.overflow-content .speed-control { + display: grid; + gap: 5px; +} +.test-provider-summary { + color: var(--text-muted); + font-size: 0.7rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-alert { + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + background: color-mix( + in srgb, + var(--blackberry-cream) 48%, + var(--background) + ); + color: #ffd5ce; +} + +body, +.sidebar, +.agent-roster, +.bottom-dock, +.modal-panel, +.modal-header, +.modal-footer { + background-color: var(--surface); + color: var(--text); +} +.panel, +.map-panel, +.agent-roster, +.bottom-dock, +.modal-header, +.modal-footer { + border-color: var(--line); +} +.modal-panel { + border-color: var(--line); + box-shadow: 0 18px 60px #000d; +} +.modal-body input, +.modal-body select, +.modal-body button, +textarea, +.model-console input, +.model-console select, +.model-console button, +.dock-heading button, +.new-message-button { + border-color: var(--line); + background: var(--surface-raised); + color: var(--text); +} +.muted, +dt, +.catalog-state, +.modal-header p, +.model-filters label, +.model-select-label, +.agent-model-overrides label { + color: var(--text-muted) !important; +} +.primary-action:not(:disabled) { + border-color: var(--mint-leaf); + background: var(--accent-strong); + color: #f6fff9; +} +.agent-swatch { + border-color: color-mix(in srgb, var(--vanilla-custard) 80%, white); + box-shadow: 0 0 0 1px #0009; +} +.communication-history li { + border-left: 3px solid transparent; + padding-left: 8px; +} +.stale-export { + border: 1px solid color-mix(in srgb, var(--warning) 60%, var(--line)); + background: color-mix(in srgb, var(--warning) 12%, var(--surface)); + color: #ffe1a3; +} +.export-agent-grid .checkbox-row { + min-height: 30px; + margin: 0; + padding: 3px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--surface-raised) 76%, transparent); +} +.export-agent-grid input[type='checkbox'] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--mint-leaf); +} +.export-dialog fieldset { + border-color: var(--line); +} +* { + scrollbar-color: var(--jungle-teal) var(--background); + scrollbar-width: thin; +} +.maplibregl-ctrl-group, +.maplibregl-ctrl-attrib { + background: var(--surface) !important; + color: var(--text-muted) !important; +} +.maplibregl-ctrl button { + filter: invert(0.85) sepia(0.18) saturate(0.7); +} +.maplibregl-ctrl-attrib a { + color: var(--celadon) !important; +} +.hex-map-popup { + position: absolute; + z-index: 8; + left: 16px; + bottom: 58px; + width: min(330px, calc(100% - 32px)); + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + color: var(--text); + box-shadow: 0 14px 36px #000b; + backdrop-filter: blur(10px); +} +.hex-popup-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.hex-popup-heading button { + width: 30px; + height: 30px; + padding: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-raised); + color: var(--text); +} +.hex-map-popup dl div { + grid-template-columns: 90px minmax(0, 1fr); +} + +@media (max-width: 1180px) { + .command-navbar { + grid-template-columns: + auto auto minmax(300px, 1fr) minmax(145px, 190px) + auto; + } + .command-brand .eyebrow { + display: none; + } +} +@media (max-width: 1100px) { + .command-navbar { + grid-template-columns: auto minmax(570px, 1fr) minmax(130px, 160px) auto; + } + .command-brand { + display: none; + } +} +@media (max-width: 820px) { + .command-navbar { + grid-template-columns: auto minmax(330px, 1fr) minmax(130px, 160px) auto; + overflow-x: auto; + } + .command-brand { + display: none; + } + .command-controls { + justify-content: flex-start; + min-width: 570px; + } +} diff --git a/apps/world-lab/src/components/map-config.ts b/apps/world-lab/src/components/map-config.ts new file mode 100644 index 0000000..bd9ff83 --- /dev/null +++ b/apps/world-lab/src/components/map-config.ts @@ -0,0 +1,7 @@ +export const DARK_TILE_URLS = [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', +] as const; + +export const DARK_TILE_ATTRIBUTION = '© OpenStreetMap contributors © CARTO'; diff --git a/apps/world-lab/src/components/model-options.test.ts b/apps/world-lab/src/components/model-options.test.ts new file mode 100644 index 0000000..2ffb050 --- /dev/null +++ b/apps/world-lab/src/components/model-options.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { CompatibleModel } from '@agentborne/shared'; +import { buildModelOptions, formatModelOption } from './model-options'; + +const model = (id: string, name: string, price = '0'): CompatibleModel => ({ + id, + name, + author: id.split('/')[0]!, + contextLength: 32_768, + inputPricePerToken: price, + outputPricePerToken: price, + supportedParameters: ['max_tokens'], + isFree: price === '0', +}); + +describe('shared model options', () => { + it('deduplicates and orders global and per-agent options identically', () => { + const catalog = [ + model('zeta/model', 'Alpha'), + model('Acme/model-2', 'Zulu', '0.000001'), + model('acme/model-1', 'Beta'), + model('zeta/model', 'Duplicate ignored'), + ]; + const globalOptions = buildModelOptions(catalog); + const agentOptions = buildModelOptions(catalog); + + expect(agentOptions).toEqual(globalOptions); + expect(globalOptions.map(({ value }) => value)).toEqual([ + 'acme/model-1', + 'Acme/model-2', + 'zeta/model', + ]); + }); + + it('places the identifier before the friendly name and preserves price metadata', () => { + expect(formatModelOption(model('sample/free', 'Friendly'))).toBe( + 'sample/free — Friendly · free in / free out', + ); + expect(formatModelOption(model('sample/paid', 'Paid', '0.000001'))).toBe( + 'sample/paid — Paid · $1.00 in / $1.00 out', + ); + }); +}); diff --git a/apps/world-lab/src/components/model-options.ts b/apps/world-lab/src/components/model-options.ts new file mode 100644 index 0000000..69ae90d --- /dev/null +++ b/apps/world-lab/src/components/model-options.ts @@ -0,0 +1,39 @@ +import type { CompatibleModel } from '@agentborne/shared'; + +export interface ModelOption { + value: string; + label: string; + model: CompatibleModel; +} + +const modelCollator = new Intl.Collator(undefined, { + sensitivity: 'base', + numeric: true, +}); + +export function formatModelOption(model: CompatibleModel): string { + const input = formatPerMillion(model.inputPricePerToken); + const output = formatPerMillion(model.outputPricePerToken); + return `${model.id} — ${model.name} · ${input} in / ${output} out`; +} + +export function buildModelOptions(models: CompatibleModel[]): ModelOption[] { + const unique = new Map(); + for (const model of models) unique.set(model.id, model); + return [...unique.values()] + .sort( + (left, right) => + modelCollator.compare(left.id, right.id) || + left.id.localeCompare(right.id), + ) + .map((model) => ({ + value: model.id, + label: formatModelOption(model), + model, + })); +} + +function formatPerMillion(pricePerToken: string): string { + const value = Number(pricePerToken) * 1_000_000; + return value === 0 ? 'free' : `$${value.toFixed(value < 0.01 ? 4 : 2)}`; +} diff --git a/apps/world-lab/src/components/ui-color.test.ts b/apps/world-lab/src/components/ui-color.test.ts new file mode 100644 index 0000000..88aea34 --- /dev/null +++ b/apps/world-lab/src/components/ui-color.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentId, SimulationSnapshot } from '@agentborne/shared'; +import { neutralAgentColor, resolveAgentColor } from './ui-color'; + +const agentId = '11111111-1111-4111-8111-111111111111' as AgentId; +const otherId = '22222222-2222-4222-8222-222222222222' as AgentId; +const state = (allied: boolean) => + ({ + world: { + agents: [{ id: agentId, color: '#123456' }], + alliances: allied + ? [{ color: '#abcdef', memberAgentIds: [agentId] }] + : [], + }, + }) as unknown as Pick; + +describe('effective agent color resolution', () => { + it('prefers current alliance color and updates when membership changes', () => { + expect(resolveAgentColor(state(true), agentId, '#654321')).toBe('#abcdef'); + expect(resolveAgentColor(state(false), agentId, '#654321')).toBe('#654321'); + }); + + it('falls back through retained, base, and neutral colors', () => { + expect(resolveAgentColor(state(false), agentId)).toBe('#123456'); + expect(resolveAgentColor(state(false), otherId, '#654321')).toBe('#654321'); + expect(resolveAgentColor(state(false), otherId)).toBe(neutralAgentColor); + }); +}); diff --git a/apps/world-lab/src/components/ui-color.ts b/apps/world-lab/src/components/ui-color.ts new file mode 100644 index 0000000..1f8a787 --- /dev/null +++ b/apps/world-lab/src/components/ui-color.ts @@ -0,0 +1,20 @@ +import type { AgentId, SimulationSnapshot } from '@agentborne/shared'; + +export const neutralAgentColor = '#b2d3a8'; + +export function resolveAgentColor( + snapshot: Pick, + agentId: AgentId, + retainedEffectiveColor?: string | null, +): string { + const agent = snapshot.world.agents.find(({ id }) => id === agentId); + const currentAlliance = snapshot.world.alliances.find(({ memberAgentIds }) => + memberAgentIds.includes(agentId), + ); + return ( + currentAlliance?.color ?? + retainedEffectiveColor ?? + agent?.color ?? + neutralAgentColor + ); +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 994d9a9..67f715e 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -30,6 +30,7 @@ const mapLibreMock = vi.hoisted(() => ({ mapClick: undefined as | ((event: { features: Array<{ properties: { cell: string } }> }) => void) | undefined, + mapBackgroundClick: undefined as (() => void) | undefined, layers: [] as Array<{ id: string; paint?: Record; @@ -128,6 +129,8 @@ vi.mock('maplibre-gl', () => { } if (event === 'click' && typeof callback === 'function') { mapLibreMock.mapClick = callback; + } else if (event === 'click' && typeof layerOrCallback === 'function') { + mapLibreMock.mapBackgroundClick = layerOrCallback as () => void; } else if ( event !== 'style.load' && typeof layerOrCallback === 'function' @@ -143,6 +146,8 @@ vi.mock('maplibre-gl', () => { this.listeners.get(event)?.delete(layerOrCallback as () => void); } if (event === 'click' && callback) mapLibreMock.mapClick = undefined; + if (event === 'click' && !callback) + mapLibreMock.mapBackgroundClick = undefined; } triggerRepaint() { const completeRender = () => this.emit('render'); @@ -842,6 +847,7 @@ function withPersonality( beforeEach(() => { window.localStorage.clear(); + window.sessionStorage.clear(); mapLibreMock.renderMode = 'complete'; mapLibreMock.rejectSource = false; mapLibreMock.rejectLayers = false; @@ -849,6 +855,7 @@ beforeEach(() => { mapLibreMock.autoRender = true; mapLibreMock.pendingRenderCallbacks = []; mapLibreMock.mapClick = undefined; + mapLibreMock.mapBackgroundClick = undefined; mapLibreMock.layers = []; mapLibreMock.queryRenderedFeatures.mockReset(); mapLibreMock.setData.mockReset(); @@ -892,10 +899,29 @@ describe('WorldLab', () => { screen.getByRole('button', { name: 'Restore default personalities' }), ).toBeEnabled(); expect(screen.getByLabelText('Playback speed')).toBeInTheDocument(); + expect( + screen.getByRole('banner', { name: 'World Lab command bar' }), + ).toBeInTheDocument(); + expect(document.querySelector('.command-bar')).not.toBeInTheDocument(); + expect(document.querySelector('.provider-badge')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Export this agent' }), + ).not.toBeInTheDocument(); + expect( + screen + .getAllByRole('option', { name: /^(25|50|100|200|500|1000)$/ }) + .map((option) => Number((option as HTMLOptionElement).value)), + ).toEqual([25, 50, 100, 200, 500, 1000]); + expect( + screen.getByLabelText('Experiment details. Turn 0, paused'), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Experiment details. Turn 0, paused'), + ).toHaveTextContent('0.0 credits'); expect( await screen.findAllByRole('button', { name: /Select agent/ }), ).toHaveLength(8); - expect(screen.getByText('Automated-test provider')).toBeInTheDocument(); + expect(screen.getByText('Deterministic test model')).toBeInTheDocument(); }); it('runs exactly 194 additional turns from turn 6 and never schedules turn 201', async () => { @@ -949,12 +975,10 @@ describe('WorldLab', () => { await Promise.resolve(); await Promise.resolve(); }); - expect( - screen.getByText(/Experiment progress: 6\/200/), - ).toBeInTheDocument(); + expect(screen.getByText('Turn 6')).toBeInTheDocument(); await act(async () => { - screen.getByRole('button', { name: 'Run to turn 200' }).click(); + screen.getByRole('button', { name: 'Run to 200' }).click(); }); for (let expectedTurn = 7; expectedTurn <= 200; expectedTurn += 1) { await act(async () => { @@ -962,9 +986,7 @@ describe('WorldLab', () => { }); } - expect( - screen.getByText(/Experiment progress: 200\/200/), - ).toBeInTheDocument(); + expect(screen.getByText('Turn 200')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); expect(turnRequests - 1).toBe(194); expect(fetch).toHaveBeenCalledTimes(195); @@ -979,6 +1001,24 @@ describe('WorldLab', () => { } }, 15_000); + it('keeps run targets absolute and makes current or past targets unavailable', async () => { + const at50 = simulationSnapshotSchema.parse({ + ...initial, + turnNumber: 50, + experiment: { ...initial.experiment, totalCompletedTurns: 50 }, + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(at50)), + ); + render(); + const selector = await screen.findByLabelText('Run target'); + expect(screen.getByRole('option', { name: '25' })).toBeDisabled(); + expect(screen.getByRole('option', { name: '50' })).toBeDisabled(); + expect(screen.getByRole('option', { name: '100' })).toBeEnabled(); + expect(selector).toHaveValue('200'); + }); + it('derives allied marker and existing-territory colors while retaining individual ownership labels', async () => { const progressed = afterInfection(); const [ember, rook] = progressed.world.agents; @@ -1438,7 +1478,11 @@ describe('WorldLab', () => { await user.click( await screen.findByRole('button', { name: 'Select agent Rook' }), ); - expect(screen.getByText('Rook', { selector: 'dd' })).toBeInTheDocument(); + expect( + within(screen.getByLabelText('Agent inspector')).getByRole('heading', { + name: /Rook/, + }), + ).toBeInTheDocument(); expect(screen.getByLabelText('Recent territory changes')).toHaveTextContent( 'Gained', ); @@ -1515,13 +1559,20 @@ describe('WorldLab', () => { render(); await screen.findByRole('button', { name: 'Select agent Ember' }); const target = world.hexes[1]!.cell; - act(() => + act(() => { mapLibreMock.mapClick?.({ features: [{ properties: { cell: target } }], - }), + }); + mapLibreMock.mapBackgroundClick?.(); + }); + expect(screen.getByLabelText('Selected hex details')).toHaveTextContent( + target, ); - expect(screen.getByText(target)).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Ember/ })).toBeInTheDocument(); + act(() => mapLibreMock.mapBackgroundClick?.()); + expect( + screen.queryByLabelText('Selected hex details'), + ).not.toBeInTheDocument(); }); it('renders missing configuration without enabling cost-incurring controls', async () => { @@ -1667,8 +1718,11 @@ describe('WorldLab', () => { expect( screen.queryByRole('dialog', { name: 'Model selection' }), ).toBeNull(); - expect(screen.getByText('Model: Beta Free')).toHaveFocus(); - await user.click(screen.getByText('Model: Beta Free')); + const agentControllerTrigger = screen.getByRole('button', { + name: /Open Agent Controller.*Beta Free/, + }); + expect(agentControllerTrigger).toHaveFocus(); + await user.click(agentControllerTrigger); fireEvent.mouseDown( screen.getByRole('dialog', { name: 'Model selection' }), ); @@ -2232,12 +2286,12 @@ describe('WorldLab', () => { ); }); - it('preselects one agent, supports multi-select and previews server-owned export', async () => { + it('supports agent selection and previews server-owned export', async () => { const user = userEvent.setup(); render(); - await user.click( - await screen.findByRole('button', { name: 'Export this agent' }), - ); + await user.click(await screen.findByRole('button', { name: 'Export' })); + await user.click(screen.getByRole('button', { name: 'Clear' })); + await user.click(screen.getByRole('checkbox', { name: /Ember/ })); expect(screen.getByRole('checkbox', { name: /Ember/ })).toBeChecked(); expect(screen.getByRole('checkbox', { name: /Rook/ })).not.toBeChecked(); await user.click(screen.getByRole('checkbox', { name: /Rook/ })); @@ -2405,23 +2459,36 @@ describe('WorldLab', () => { .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => undefined); render(); - await user.click( - await screen.findByRole('button', { name: 'Export this agent' }), - ); + await user.click(await screen.findByRole('button', { name: 'Export' })); + expect(screen.getByRole('button', { name: 'Copy JSON' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Download JSON' }), + ).toBeDisabled(); const document = minimalExportDocument(progressed); vi.mocked(fetch).mockImplementationOnce(() => jsonResponse({ document })); - await user.click(screen.getByRole('button', { name: 'Generate' })); - await user.click(await screen.findByRole('button', { name: 'Copy' })); + await user.click(screen.getByRole('button', { name: 'Generate export' })); + await user.click(await screen.findByRole('button', { name: 'Copy JSON' })); expect(clipboardWrite).toHaveBeenCalledWith( JSON.stringify(experimentExportDocumentSchema.parse(document)), ); clipboardWrite.mockRejectedValueOnce(new Error('denied')); - await user.click(screen.getByRole('button', { name: 'Copy' })); + await user.click(screen.getByRole('button', { name: 'Copy JSON' })); expect(await screen.findByText(/Copy failed/)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Download' })); + await user.click(screen.getByRole('button', { name: 'Download JSON' })); expect(createObjectURL).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith('blob:experiment'); expect(click).toHaveBeenCalledTimes(1); + await user.selectOptions( + screen.getByLabelText('JSON serialization'), + 'pretty', + ); + expect(screen.getByRole('button', { name: 'Copy JSON' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Download JSON' }), + ).toBeDisabled(); + expect( + screen.getByText('Options changed — regenerate export.'), + ).toBeInTheDocument(); click.mockRestore(); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 3b32c37..9e92d3c 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -53,6 +53,8 @@ import { PERSONALITY_PRESETS, } from './personality-presets'; import { WorldMap } from './world-map'; +import { buildModelOptions } from './model-options'; +import { resolveAgentColor } from './ui-color'; const latitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LATITUDE ?? 41.6528); const longitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LONGITUDE ?? -83.5379); @@ -60,13 +62,17 @@ 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'; const followTurnStorageKey = 'agentborne.world-lab.follow-turn'; +const runTargetStorageKey = 'agentborne.world-lab.run-target'; +export const runTargets = [25, 50, 100, 200, 500, 1000] as const; export function WorldLab() { const [snapshot, setSnapshot] = useState(null); const [selectedCell, setSelectedCell] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [running, setRunning] = useState(false); - const [runToTurn200, setRunToTurn200] = useState(false); + const [runTarget, setRunTarget] = useState<(typeof runTargets)[number]>(200); + const [runTargetLoaded, setRunTargetLoaded] = useState(false); + const [boundedRunTarget, setBoundedRunTarget] = useState(null); const [inFlight, setInFlight] = useState(false); const [reconciling, setReconciling] = useState(false); const [resetting, setResetting] = useState(false); @@ -80,6 +86,7 @@ export function WorldLab() { const [exportAgentIds, setExportAgentIds] = useState([]); const [catalog, setCatalog] = useState(null); const [catalogLoading, setCatalogLoading] = useState(false); + const [configurationPending, setConfigurationPending] = useState(false); const [modelVerifications, setModelVerifications] = useState< Record >({}); @@ -89,15 +96,32 @@ export function WorldLab() { const [followTurn, setFollowTurn] = useState(true); const [followPreferenceLoaded, setFollowPreferenceLoaded] = useState(false); const inFlightRef = useRef(false); - const runToTurn200Ref = useRef(false); + const boundedRunTargetRef = useRef(null); const completedTurnsRef = useRef(0); const mutationSequenceRef = useRef(0); + const configurationPendingRef = useRef(false); const exportInitializedRef = useRef(false); const exportTriggerRef = useRef(null); useEffect(() => { - runToTurn200Ref.current = runToTurn200; - }, [runToTurn200]); + boundedRunTargetRef.current = boundedRunTarget; + }, [boundedRunTarget]); + + useEffect(() => { + const stored = Number(window.sessionStorage.getItem(runTargetStorageKey)); + const hydrationTask = window.setTimeout(() => { + if (runTargets.includes(stored as (typeof runTargets)[number])) { + setRunTarget(stored as (typeof runTargets)[number]); + } + setRunTargetLoaded(true); + }, 0); + return () => window.clearTimeout(hydrationTask); + }, []); + + useEffect(() => { + if (!runTargetLoaded) return; + window.sessionStorage.setItem(runTargetStorageKey, String(runTarget)); + }, [runTarget, runTargetLoaded]); useEffect(() => { const storedFollowTurn = @@ -123,13 +147,13 @@ export function WorldLab() { next.status === 'provider-error' || next.status === 'configuration-error' || next.world.hexes.every(({ state }) => state === 'infected') || - (runToTurn200Ref.current && next.experiment.totalCompletedTurns >= 200) + (boundedRunTargetRef.current !== null && + next.experiment.totalCompletedTurns >= boundedRunTargetRef.current) ) { setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; } - setSelectedCell((current) => current ?? next.world.hexes[0]!.cell); setSelectedAgentId((current) => current ?? next.world.agents[0]!.id); }, []); @@ -223,6 +247,9 @@ export function WorldLab() { const updateModels = async ( configuration: Omit, ): Promise => { + if (configurationPendingRef.current) return false; + configurationPendingRef.current = true; + setConfigurationPending(true); setUiError(null); try { const response = await fetch(`${apiBase}/experiment/models`, { @@ -246,12 +273,18 @@ export function WorldLab() { } catch { setUiError('The model assignment could not be saved.'); return false; + } finally { + configurationPendingRef.current = false; + setConfigurationPending(false); } }; const updateBehavior = async ( configuration: Omit, ): Promise => { + if (configurationPendingRef.current) return false; + configurationPendingRef.current = true; + setConfigurationPending(true); setUiError(null); try { const response = await fetch(`${apiBase}/experiment/behavior`, { @@ -270,6 +303,9 @@ export function WorldLab() { 'Behavior assignments could not be saved. They may be locked after turn one.', ); return false; + } finally { + configurationPendingRef.current = false; + setConfigurationPending(false); } }; @@ -336,10 +372,13 @@ export function WorldLab() { const executeTurn = useCallback( async (operation: 'turn' | 'retry' = 'turn') => { if (inFlightRef.current) return; - if (runToTurn200Ref.current && completedTurnsRef.current >= 200) { + if ( + boundedRunTargetRef.current !== null && + completedTurnsRef.current >= boundedRunTargetRef.current + ) { setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; return; } inFlightRef.current = true; @@ -356,8 +395,8 @@ export function WorldLab() { if (response.status === 409) { setUiError('Another turn is already in progress.'); setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; return; } if (!response.ok) throw new Error('turn request failed'); @@ -372,8 +411,8 @@ export function WorldLab() { applySnapshot(payload.snapshot); if (payload.turn.outcome === 'provider-error') { setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; const failedAgent = payload.snapshot.world.agents.find( ({ id }) => id === payload.turn.agentId, ); @@ -384,8 +423,8 @@ export function WorldLab() { } catch { setUiError('The response was lost. Reconciling with the Game API…'); setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; try { await reconcileAuthoritativeSnapshot(); setUiError(null); @@ -423,8 +462,8 @@ export function WorldLab() { const cancelCurrentRequest = async () => { setCancelling(true); setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; try { const response = await fetch(`${apiBase}/turn/cancel`, { method: 'POST', @@ -447,7 +486,11 @@ export function WorldLab() { useEffect(() => { if (!running || inFlight || resetting) return; - if (runToTurn200Ref.current && completedTurnsRef.current >= 200) return; + if ( + boundedRunTargetRef.current !== null && + completedTurnsRef.current >= boundedRunTargetRef.current + ) + return; const timer = window.setTimeout(() => void executeTurn(), speed); return () => window.clearTimeout(timer); }, [executeTurn, inFlight, resetting, running, snapshot?.turnNumber, speed]); @@ -476,8 +519,8 @@ export function WorldLab() { ) return; setRunning(false); - setRunToTurn200(false); - runToTurn200Ref.current = false; + setBoundedRunTarget(null); + boundedRunTargetRef.current = null; setResetting(true); setUiError(null); try { @@ -493,7 +536,7 @@ export function WorldLab() { completedTurnsRef.current = payload.snapshot.experiment.totalCompletedTurns; setSnapshot(payload.snapshot); - setSelectedCell(payload.snapshot.world.hexes[0]!.cell); + setSelectedCell(null); setSelectedAgentId(payload.snapshot.world.agents[0]!.id); } catch { setUiError('Reset failed safely. The existing world was left intact.'); @@ -604,8 +647,6 @@ export function WorldLab() { const selectAgentForInspection = (agentId: AgentId) => { setFollowTurn(false); setSelectedAgentId(agentId); - const agent = snapshot.world.agents.find(({ id }) => id === agentId); - if (agent) setSelectedCell(agent.currentCell); }; const selectedHex = snapshot.world.hexes.find( ({ cell }) => cell === selectedCell, @@ -667,46 +708,98 @@ export function WorldLab() {
-
-
-

Developer simulation interface

-

World Lab

-
-
-

-

-

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

-
-
- -
-
- Turn {snapshot.turnNumber} - {status.replaceAll('-', ' ')} - {reconciling && Reconciling request…} - - {formatCost(snapshot.experiment.metrics.aggregate.knownCostCredits)} +
+
+ +
+

Developer simulation

+

World Lab

+
-
+
+ +
+

Current experiment

+
+
+
State
+
{status.replaceAll('-', ' ')}
+
+
+
Retained turns
+
{snapshot.experiment.retainedTurns}
+
+
+
Public messages
+
+ {snapshot.experiment.metrics.aggregate.publicMessagesSent} +
+
+
+
Direct messages
+
+ {snapshot.experiment.metrics.aggregate.directMessagesSent} +
+
+
+
Known credits
+
+ {formatCost( + snapshot.experiment.metrics.aggregate.knownCostCredits, + )} +
+
+
+
Tokens
+
+ {snapshot.experiment.metrics.aggregate.tokens.totalTokens ?? + 'Unknown'} +
+
+
+ +
+
+
+ {snapshot.providerMode === 'openrouter' ? ( ) : ( -

Model: deterministic test provider

+

Deterministic test model

)} -
- - Experiment progress:{' '} - {Math.min(snapshot.experiment.totalCompletedTurns, 200)}/200 - {runToTurn200 ? ' · bounded run active' : ''} - - {!running && (inFlight || snapshot.activeAgentId !== null) && ( - - Playback is paused; the current provider request is still - finishing. - - )} - + - -
-
+ {personalityPending + ? 'Restoring…' + : 'Restore default personalities'} + + + +
{(!modelsReady || uiError || fullyInfected || personalityNotice) && (
@@ -933,13 +1085,23 @@ export function WorldLab() { hexes={snapshot.world.hexes} agents={snapshot.world.agents} alliances={snapshot.world.alliances} - selectedCell={selectedCell ?? snapshot.world.hexes[0]!.cell} + selectedCell={selectedCell} selectedAgentId={inspectionAgentId} onSelectCell={setSelectedCell} + onClearCellSelection={() => setSelectedCell(null)} onSelectAgent={(agentId) => { + setSelectedCell(null); selectAgentForInspection(agentId); }} /> + {selectedHex && ( + setSelectedCell(null)} + /> + )}
Development location: Toledo, Ohio @@ -997,92 +1159,16 @@ export function WorldLab() { (event.controllerAgentId === selectedAgent.id || event.previousControllerAgentId === selectedAgent.id), )} - onExportAgent={(agentId) => { - setRunning(false); - exportInitializedRef.current = true; - setExportAgentIds([agentId]); - setExportOpen(true); - }} /> )} - - {selectedHex && ( -
-

Selected hex

-

{selectedHex.state === 'infected' ? 'Infected' : 'Open'}

-
-
-
H3 index
-
{selectedHex.cell}
-
-
-
Controlled by
-
- {selectedHex.state === 'infected' ? ( - <> - - {selectedHexController?.name ?? - selectedHex.controllerAgentId} - - ) : ( - 'No controller' - )} -
-
-
-
Base color
-
{selectedHexController?.color ?? 'None'}
-
-
-
Alliance
-
- {selectedHexAlliance - ? selectedHexAlliance.memberAgentIds - .map( - (id) => - snapshot.world.agents.find( - (agent) => agent.id === id, - )?.name, - ) - .join(', ') - : 'Unaffiliated'} -
-
-
-
Effective territory color
-
- {selectedHexAlliance?.color ?? - selectedHexController?.color ?? - 'None'} -
-
-
-
State
-
- - {selectedHex.state} -
-
-
-
- )}
)} - {models.map((model) => ( - + )} + {modelOptions.map((option) => ( + ))} @@ -1589,11 +1738,13 @@ function ModelConsole({ {override.modelId} — unavailable )} - {(catalog?.models ?? []).map((model) => ( - - ))} + {buildModelOptions(catalog?.models ?? []).map( + (option) => ( + + ), + )}