clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / pages / viewer.ts
viewer.ts 33.9K
1/**2 * Public sim viewer (/s/:simId) — a GAME SCREEN, not a dashboard.3 *4 * Layout: top HUD status bar (identity, CHRONICLE/WORLD mode toggle,5 * transport, tick clock), a framed main viewport (chronicle telemetry or the6 * Urizen world map), a terminal world-log strip beneath it, and a right rail7 * of faction HUD panels (portrait, vitals, scrolling feed).8 *9 * Metrics-first flow (share links open instantly):10 * - GET /api/sims/:id/metrics 200 → the complete chronicle + endgame banner11 * render immediately from stored frames; the WORLD mode offers WATCH THE12 * REPLAY, which only then boots the deterministic SimRunner.13 * - 404 → auto-replay at MAX speed with a "simulating world" note; when done,14 * the accumulated frames + result + stateHash are gzipped and POSTed so the15 * next viewer is instant.16 * - ?mock=1 → local dev-mock engine, no metrics fetch.17 */18import type { MetricsFrame, SimBundle, SimEvent, SimResult, TribeMetrics } from '@tribes/protocol';19import { LIMITS } from '@tribes/protocol';20import { api, type SimMetricsPayload } from '../api';21import { h, clear } from '../dom';22import { log } from '../log';23import type { AppCtx } from '../main';24import type { MapFrame, LogLine } from '../sim/frames';25import { SimRunner, type Speed } from '../sim/runner';26import { createEffects } from '../render/effects';27import { createMapView } from '../render/map-view';28import { loadUrizen, TILE, type UrizenAtlas } from '../render/urizen';29import { createDashboard } from './dashboard';30import { tribeColor } from '../viz/palette';31import { fmtCompact } from '../viz/charts';3233function mockBundle(simId: string): SimBundle {34 const tribeCount = 4;35 return {36 sim: {37 id: simId,38 seed: '123456789',39 config: { tribeCount, mapSize: LIMITS.MAP_SIZE, maxTicks: LIMITS.MAX_TICKS },40 scripts: Array.from({ length: tribeCount }, (_, slot) => ({41 slot,42 scriptId: `lg_mock${slot}`,43 scriptName: ['gatherers', 'raiders', 'turtles', 'nomads'][slot] ?? `mock-${slot}`,44 version: 1,45 hash: `mockhash${slot}`,46 })),47 createdAt: new Date().toISOString(),48 expiresAt: null,49 starred: false,50 url: location.href,51 visibility: 'private',52 kind: 'user',53 },54 sources: Object.fromEntries(55 Array.from({ length: tribeCount }, (_, slot) => [`mockhash${slot}`, '// mock script — engine mock drives itself']),56 ),57 };58}5960export function renderViewer(main: HTMLElement, simId: string, ctx: AppCtx): () => void {61 const mock = new URLSearchParams(location.search).get('mock') === '1';62 let cleanup: (() => void) | null = null;63 let disposed = false;6465 main.append(h('p', { class: 'notice' }, `▚▞ loading sim ${simId} …`));6667 void (async () => {68 let bundle: SimBundle;69 let stored: SimMetricsPayload | null = null;70 try {71 if (mock) {72 bundle = mockBundle(simId);73 } else {74 // Metrics first: a stored payload makes the chronicle instant.75 [bundle, stored] = await Promise.all([76 api.getSimBundle(simId),77 api.getSimMetrics(simId).catch((err) => {78 log.warn('metrics fetch failed — falling back to live replay', err);79 return null;80 }),81 ]);82 }83 } catch (err) {84 if (disposed) return;85 clear(main);86 main.append(87 h('div', { class: 'card' },88 h('h2', null, 'Sim not found'),89 h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)),90 h('p', { class: 'muted' }, 'It may have expired (unstarred sims live 14 days), or the link is wrong.'),91 ),92 );93 return;94 }95 if (disposed) return;96 cleanup = buildViewer(main, bundle, mock, stored, ctx);97 })();9899 return () => {100 disposed = true;101 cleanup?.();102 };103}104105type Mode = 'chronicle' | 'world';106107function buildViewer(108 main: HTMLElement,109 bundle: SimBundle,110 mock: boolean,111 stored: SimMetricsPayload | null,112 _ctx: AppCtx,113): () => void {114 clear(main);115 main.classList.add('game-screen');116 const { sim } = bundle;117 const mapSize = sim.config.mapSize;118 const maxTicks = sim.config.maxTicks;119 const names = sim.scripts.map((s) => s.scriptName);120 const tickPad = String(maxTicks).length;121 const fmtTick = (t: number) => `T ${String(t).padStart(tickPad, '0')}/${maxTicks}`;122123 // ---- HUD status bar -----------------------------------------------------124 const playBtn = h('button', { disabled: true }, 'pause');125 const speedBtns = ([1, 4, 16, 'max'] as Speed[]).map((s) =>126 h('button', { class: 'small', disabled: true, dataset: { speed: String(s) } }, s === 'max' ? 'max' : `${s}×`),127 );128 const restartBtn = h('button', { class: 'small', disabled: true }, 'restart');129 const tickLabel = h('span', { class: 'tick-label' }, fmtTick(0));130 const progressFill = h('div');131 const progress = h('div', { class: 'progress-bar' }, progressFill);132133 const chronicleBtn = h('button', { class: 'mode-btn active' }, 'chronicle');134 const worldBtn = h('button', { class: 'mode-btn' }, 'world');135136 // compact endgame chip — appears in the HUD bar once the age has ended;137 // clicking it reopens the game-over overlay.138 const victorChip = h('button', { class: 'victor-chip', style: 'display:none', title: 'view final standings' });139140 const statusBar = h('header', { class: 'hud-status' },141 h('div', { class: 'hud-ident' },142 h('h1', null, `sim ${sim.id.replace(/^sim_/, '')}`),143 h('span', { class: 'sub' }, `seed ${sim.seed} - ${sim.config.tribeCount} tribes - map ${mapSize}`),144 mock ? h('span', { class: 'hud-mock' }, '[mock engine]') : null,145 ),146 h('div', { class: 'mode-toggle' }, chronicleBtn, worldBtn),147 h('div', { class: 'transport' }, playBtn, ...speedBtns, restartBtn),148 victorChip,149 h('div', { class: 'hud-clock' }, progress, tickLabel),150 );151152 const banner = h('div'); // transient strip: "simulating world" note only153 const hashLine = h('p', { class: 'hash-line' });154 // endgame overlay — sits OVER the viewport (dimmed), never pushes layout155 const endOverlay = h('div', { class: 'viewport-overlay', style: 'display:none' });156157 // ---- chronicle viewport (telemetry scrolls INSIDE the pane) -------------158 const dashboard = createDashboard(names);159 const chroniclePane = h('div', { class: 'viewport' },160 h('span', { class: 'viewport-label' }, 'chronicle'),161 h('div', { class: 'viewport-scroll' }, dashboard.el),162 );163164 // ---- world viewport (map view owned by render/map-view) -----------------165 const fitBtn = h('button', { class: 'small', title: 'reset view — zoom to fit' }, 'fit');166 const povSel = h(167 'select',168 null,169 h('option', { value: '-1' }, 'POV: omniscient'),170 sim.scripts.map((s) => h('option', { value: String(s.slot) }, `POV: ${s.scriptName} (slot ${s.slot})`)),171 );172 const worldToolbar = h('div', { class: 'row world-toolbar' }, povSel, fitBtn);173 const mapHost = h('div', { class: 'map-host' });174 const effects = createEffects();175 // Dev console hook: drive effect visuals by hand (e.g. __fx.ingest({pov:-1,176 // eventsByTribe:{}, world:[...]}) with synthetic events) while tuning.177 if (import.meta.env.DEV) (window as unknown as { __fx?: unknown }).__fx = effects;178 const mapView = createMapView(mapHost, { mapSize, names, effects });179180 // replay invitation — shown in instant mode until the runner boots181 const inviteGlyphs = h('div', { class: 'glyphs' });182 const replayBtn = h('button', { class: 'replay-btn' }, '▶ watch the replay');183 const replayInvite = h('div', { class: 'replay-invite', style: 'display:none' },184 inviteGlyphs,185 replayBtn,186 h('div', { class: 'hint' },187 'The chronicle is already written. Replay the age tick by tick — deterministic, exactly as it happened.'),188 );189190 const worldPane = h('div', { class: 'viewport', style: 'display:none' },191 h('span', { class: 'viewport-label' }, 'world'),192 worldToolbar,193 mapHost,194 replayInvite,195 );196197 // ---- side rail: mode-dependent ------------------------------------------198 // World mode: live faction panels (vitals + feed). Chronicle mode: identity199 // cards only — icon, name, and the script's self-description (the numbers200 // are already on the charts).201 const panels = sim.scripts.map((s) => makeTribePanel(s.slot, s.scriptName));202 const tribeFeeds = sim.scripts.map(() => makeEventFeed(names, { compact: true }));203 for (const [i, p] of panels.entries()) p.root.append(tribeFeeds[i]!.root);204 const identityCards = sim.scripts.map((s) =>205 makeIdentityCard(s.slot, s.scriptName, scriptDescription(bundle.sources[s.hash] ?? ''), `${s.scriptId} v${s.version}`),206 );207 const liveRail = h('div', { class: 'rail-group' }, ...panels.map((p) => p.root));208 const identityRail = h('div', { class: 'rail-group' }, ...identityCards.map((c) => c.root));209 liveRail.style.display = 'none'; // chronicle is the default mode210 const worldFeed = makeEventFeed(names, { title: 'world log' });211 worldFeed.root.classList.add('world-log');212213 main.append(214 statusBar,215 banner,216 h('div', { class: 'game-layout' },217 h('div', { class: 'game-main' }, chroniclePane, worldPane, hashLine, endOverlay),218 // ONE internally-scrolling rail: mode-dependent tribe cards + world feed219 h('div', { class: 'game-side' }, identityRail, liveRail, worldFeed.root),220 ),221 );222223 // ---- Urizen portraits (shared atlas; draws when loaded) -----------------224 let atlas: UrizenAtlas | null = null;225 void loadUrizen()226 .then((a) => {227 atlas = a;228 for (const [slot, p] of panels.entries()) p.drawPortrait(a, slot);229 for (const [slot, c] of identityCards.entries()) c.drawPortrait(a, slot);230 // flavor row on the replay invite: the rival banners, side by side231 clear(inviteGlyphs);232 for (let slot = 0; slot < names.length; slot++) {233 const c = h('canvas', { width: TILE, height: TILE, style: 'width:36px;height:36px' });234 const cx = c.getContext('2d');235 if (cx) a.draw(cx, `tribe.${slot % 8}`, 0, 0, TILE, tribeColor(slot));236 inviteGlyphs.append(c);237 }238 })239 .catch((err) => log.warn('urizen atlas failed to load (portraits skipped)', err));240241 // ---- state --------------------------------------------------------------242 let runner: SimRunner | null = null;243 let latestFrame: MapFrame | null = null;244 let mapDirty = false;245 let dashDirty = false;246 let mode: Mode = 'chronicle';247 const instant = stored != null;248 const postNeeded = !mock && stored == null;249 let posted = false;250 /** Frames accumulated for the first-write POST (dedup like the dashboard). */251 const metricsHistory: MetricsFrame[] = [];252253 const setTransportEnabled = (on: boolean) => {254 playBtn.disabled = !on;255 restartBtn.disabled = !on;256 for (const b of speedBtns) b.disabled = !on;257 };258 const markSpeed = (s: Speed) => {259 for (const b of speedBtns) b.classList.toggle('active', b.dataset.speed === String(s));260 };261262 const showEnd = (result: SimResult, stateHash: string) => {263 clear(banner); // drop the "simulating world" strip, if any264 showResult(endOverlay, result, names, atlas, () => setMode('chronicle'));265 const winnerName = result.winner != null ? (names[result.winner] ?? `tribe ${result.winner}`) : null;266 victorChip.textContent = winnerName != null ? `★ ${winnerName} victorious` : '— stalemate —';267 victorChip.style.display = '';268 hashLine.textContent = `final state hash ${stateHash}`;269 tickLabel.textContent = fmtTick(result.endedAt);270 progressFill.style.width = `${((result.endedAt / maxTicks) * 100).toFixed(2)}%`;271 };272 victorChip.onclick = () => {273 if (endOverlay.childElementCount > 0) endOverlay.style.display = '';274 };275276 // ---- instant chronicle (stored metrics) ---------------------------------277 if (stored) {278 for (const f of stored.frames) dashboard.push(f);279 dashDirty = true;280 showEnd(stored.result, stored.stateHash);281 // faction panels open on their FINAL vitals, not zeros282 const lastFrame = stored.frames[stored.frames.length - 1];283 if (lastFrame) for (const m of lastFrame.tribes) panels[m.tribeId]?.prime(m);284 replayInvite.style.display = '';285 mapHost.style.display = 'none';286 worldToolbar.style.display = 'none';287 }288289 // ---- runner (booted lazily in instant mode) -----------------------------290 const makeRunner = () =>291 new SimRunner(bundle, mock, {292 onFrame(frame) {293 latestFrame = frame;294 if (frame.hasMap) {295 mapDirty = true;296 effects.ingest(frame); // map effects only spawn while watching297 }298 // Append here (not in the paint loop): frames can arrive faster than299 // paints, and each frame's batch must reach the feeds exactly once.300 worldFeed.append(frame.world);301 for (const [slot, tf] of tribeFeeds.entries()) {302 tf.append(frame.eventsByTribe[slot] ?? []);303 tf.appendLogs(frame.logsByTribe[slot] ?? []);304 }305 },306 onMetrics(frame) {307 dashboard.push(frame);308 dashDirty = true;309 if (postNeeded) {310 const last = metricsHistory[metricsHistory.length - 1];311 if (!last || frame.tick > last.tick) metricsHistory.push(frame);312 }313 },314 onDone(result, stateHash) {315 playBtn.textContent = 'play';316 playBtn.disabled = true;317 showEnd(result, stateHash);318 dashDirty = true; // flush the final metrics frame into the charts319 if (postNeeded && !posted) {320 posted = true;321 void api322 .postSimMetrics(sim.id, { frames: metricsHistory, result, stateHash })323 .then(() => log.info('metrics stored — next viewer is instant'))324 .catch((err) => log.warn('metrics store failed (next viewer will re-simulate)', err));325 }326 },327 onFatal(error) {328 playBtn.disabled = true;329 clear(banner);330 clear(endOverlay);331 const closeBtn = h('button', null, '[ close ]');332 closeBtn.onclick = () => { endOverlay.style.display = 'none'; };333 endOverlay.append(334 h('div', { class: 'result-banner failed' },335 h('h2', null, '✕ sim failed ✕'),336 h('div', { class: 'result-scroll' },337 h('p', { class: 'error-text', style: 'white-space:pre-wrap' }, error),338 mock ? null : h('p', { class: 'muted' }, 'The engine/script-host packages may not be integrated yet — try ?mock=1.'),339 ),340 h('div', { class: 'row result-actions' }, closeBtn),341 ),342 );343 endOverlay.style.display = '';344 },345 });346347 const bootRunner = (speed: Speed) => {348 if (runner) return;349 runner = makeRunner();350 runner.setSpeed(speed);351 markSpeed(speed);352 setTransportEnabled(true);353 playBtn.textContent = 'pause';354 // hide the invite; the map takes over355 replayInvite.style.display = 'none';356 mapHost.style.display = '';357 worldToolbar.style.display = '';358 runner.setPov(Number(povSel.value));359 runner.setWatch(mode === 'world');360 runner.start();361 runner.playing = true;362 };363364 if (mock) {365 bootRunner(4);366 } else if (!instant) {367 // No precomputed metrics: simulate the world at MAX speed, then store it.368 banner.append(369 h('div', { class: 'simulating-note' },370 '▚ simulating world — the chronicle writes itself ',371 h('span', { class: 'blink' }, '█'),372 ),373 );374 bootRunner('max');375 }376377 replayBtn.onclick = () => bootRunner(4);378379 // ---- render loop (rAF-throttled; decoupled from sim rate) ---------------380 let raf = 0;381 const paint = () => {382 raf = requestAnimationFrame(paint);383 if (dashDirty && mode === 'chronicle') {384 dashboard.render();385 dashDirty = false;386 }387 // Redraw on new frames (mapDirty) AND every rAF while effects animate —388 // the terrain layer stays cached, only the entity composite re-runs.389 if ((mapDirty || effects.active()) && mode === 'world' && latestFrame && latestFrame.hasMap) {390 mapView.draw(latestFrame);391 mapDirty = false;392 }393 if (latestFrame && runner && !runner.finished) {394 tickLabel.textContent = fmtTick(latestFrame.tick);395 progressFill.style.width = `${((latestFrame.tick / latestFrame.maxTicks) * 100).toFixed(2)}%`;396 }397 if (latestFrame) for (const p of panels) p.update(latestFrame);398 };399 raf = requestAnimationFrame(paint);400401 // ---- mode toggle --------------------------------------------------------402 const setMode = (m: Mode) => {403 if (mode === m) return;404 mode = m;405 chronicleBtn.classList.toggle('active', m === 'chronicle');406 worldBtn.classList.toggle('active', m === 'world');407 chroniclePane.style.display = m === 'chronicle' ? '' : 'none';408 worldPane.style.display = m === 'world' ? '' : 'none';409 // Entering the world dismisses the endgame overlay — it would otherwise410 // sit over (and intercept clicks on) the WATCH THE REPLAY invite. The411 // HUD victor chip brings it back on demand.412 if (m === 'world') endOverlay.style.display = 'none';413 // Rail: identity cards in chronicle, live vitals+feeds in world.414 identityRail.style.display = m === 'chronicle' ? '' : 'none';415 liveRail.style.display = m === 'world' ? '' : 'none';416 // Map frames only stream while watching.417 runner?.setWatch(m === 'world');418 if (m === 'chronicle') dashDirty = true;419 };420 chronicleBtn.onclick = () => setMode('chronicle');421 worldBtn.onclick = () => setMode('world');422423 // ---- transport ----------------------------------------------------------424 playBtn.onclick = () => {425 if (!runner) return;426 runner.setPlaying(!runner.playing);427 playBtn.textContent = runner.playing ? 'pause' : 'play';428 };429 for (const btn of speedBtns) {430 btn.onclick = () => {431 if (!runner) return;432 const v = btn.dataset.speed!;433 runner.setSpeed(v === 'max' ? 'max' : (Number(v) as Speed));434 markSpeed(runner.speed);435 };436 }437 restartBtn.onclick = () => {438 if (!runner) return;439 hashLine.textContent = '';440 latestFrame = null;441 playBtn.disabled = false;442 playBtn.textContent = 'pause';443 worldFeed.reset();444 for (const tf of tribeFeeds) tf.reset();445 effects.clear();446 if (!instant) {447 // live-run page: the chronicle rebuilds with the replay448 clear(banner);449 clear(endOverlay);450 endOverlay.style.display = 'none';451 victorChip.style.display = 'none';452 dashboard.reset();453 metricsHistory.length = 0;454 } else {455 // instant page: result stands — just tuck the overlay away for the replay456 endOverlay.style.display = 'none';457 }458 // instant page: the chronicle and endgame banner are already final — keep459 // them; dashboard.push() dedupes the re-run's frames.460 dashDirty = true;461 runner.setPov(runner.pov); // keep pov462 runner.restart(); // start() re-sends the watch flag463 };464 // camera lives in the map view (wheel zoom, drag pan, minimap); this only465 // resets it to the default zoom-to-fit framing.466 fitBtn.onclick = () => mapView.resetView();467 povSel.onchange = () => {468 // POV only affects the map — feeds are per-tribe channels.469 // Drop live effects: the old POV's battles may be fogged for the new one.470 effects.clear();471 runner?.setPov(Number(povSel.value));472 };473474 // ---- map hover tooltips (owned by the map view; frame supplied by us) ---475 const mapTips = mapView.attachTooltips(() => (latestFrame && latestFrame.hasMap ? latestFrame : null));476477 return () => {478 main.classList.remove('game-screen');479 cancelAnimationFrame(raf);480 mapTips.dispose();481 mapView.dispose();482 dashboard.dispose();483 runner?.dispose();484 };485}486487// --------------------------------------------------------------------------488// Event feed — MUD-style scrolling narration built from frame feed channels.489490const FEED_MAX_LINES = 300;491const COMPACT_FEED_MAX_LINES = 120;492493interface EventFeed {494 root: HTMLElement;495 append(events: SimEvent[]): void;496 /** api.log lines — the tribe's internal monologue, rendered as » thoughts. */497 appendLogs(lines: LogLine[]): void;498 reset(): void;499}500501interface FeedOptions {502 /** Panel header; omitted for compact in-card feeds. */503 title?: string;504 /** Small in-card variant (shorter, fewer retained lines). */505 compact?: boolean;506}507508function makeEventFeed(names: string[], opts: FeedOptions = {}): EventFeed {509 const maxLines = opts.compact ? COMPACT_FEED_MAX_LINES : FEED_MAX_LINES;510 const body = h('div', { class: opts.compact ? 'feed-body compact' : 'feed-body' });511 const root = h(512 'div',513 { class: opts.compact ? 'feed-panel in-card' : 'feed-panel' },514 opts.title !== undefined ? h('div', { class: 'feed-head' }, opts.title) : null,515 body,516 );517 // Auto-scroll to newest unless the user has scrolled up to read history.518 let stick = true;519 body.addEventListener('scroll', () => {520 stick = body.scrollTop + body.clientHeight >= body.scrollHeight - 8;521 });522523 const nameSpanRaw = (id: number) =>524 id < 0525 ? h('span', { class: 'feed-name', style: 'color:#9a6a4a' }, 'raiders')526 : null;527 const nameSpan = (id: number) =>528 nameSpanRaw(id) ??529 h('span', { class: 'feed-name' },530 h('span', { class: 'feed-swatch', style: `background:${tribeColor(id)}` }),531 h('span', { style: `color:${tribeColor(id)}` }, names[id] ?? `tribe ${id}`),532 );533 const at = (x: number, y: number) => ` at (${x}, ${y})`;534 const noun = (k: string) => k.replace(/_/g, ' ');535 const lootText = (loot: Partial<Record<string, number>>) => {536 const parts = Object.entries(loot)537 .filter(([, v]) => (v ?? 0) > 0)538 .slice(0, 4)539 .map(([k, v]) => `${v} ${k}`);540 return parts.length > 0 ? ` — loot: ${parts.join(', ')}` : '';541 };542543 // Every piece of event content goes through h() text nodes — 'say' text is544 // untrusted player content and must never be rendered via innerHTML.545 function line(ev: SimEvent): HTMLElement {546 const tick = h('span', { class: 'feed-tick' }, `[t${ev.tick}] `);547 switch (ev.t) {548 case 'say':549 return h('div', { class: 'feed-line feed-say' },550 tick, nameSpan(ev.tribe), ': ', h('span', { class: 'feed-say-text' }, `"${ev.text}"`));551 case 'settlement_founded':552 return h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), ` founded a settlement${at(ev.x, ev.y)}`);553 case 'settlement_captured':554 return h('div', { class: 'feed-line feed-fate' },555 tick, nameSpan(ev.tribe), `'s settlement${at(ev.x, ev.y)} was captured by `, nameSpan(ev.by));556 case 'building_complete':557 return h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), ` completed a ${noun(ev.building)}`);558 case 'building_destroyed':559 return ev.by == null560 ? h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), `'s ${noun(ev.building)} was destroyed`)561 : h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), `'s ${noun(ev.building)} destroyed by `, nameSpan(ev.by));562 case 'battle': {563 const losses = ` (${ev.attackerLosses} vs ${ev.defenderLosses} losses)`;564 if (ev.defender == null) {565 // fighting a neutral threat566 return ev.winner != null567 ? h('div', { class: 'feed-line' }, tick, nameSpan(ev.attacker), ` won a battle against a threat${at(ev.x, ev.y)}${losses}`)568 : h('div', { class: 'feed-line' }, tick, nameSpan(ev.attacker), ` fought a threat${at(ev.x, ev.y)}${losses}`);569 }570 if (ev.winner == null)571 return h('div', { class: 'feed-line' },572 tick, nameSpan(ev.attacker), ' and ', nameSpan(ev.defender), ` fought to a stand-off${at(ev.x, ev.y)}${losses}`);573 const loser = ev.winner === ev.attacker ? ev.defender : ev.attacker;574 return h('div', { class: 'feed-line' },575 tick, nameSpan(ev.winner), ' defeated ', nameSpan(loser), at(ev.x, ev.y), losses);576 }577 case 'raid':578 return h('div', { class: 'feed-line' },579 tick, nameSpan(ev.tribe), `'s settlement was raided by `, nameSpan(ev.by), lootText(ev.loot));580 case 'caravan_raided':581 return h('div', { class: 'feed-line' },582 tick, nameSpan(ev.tribe), `'s caravan was raided by `, nameSpan(ev.by), ` — lost ${ev.amount} ${ev.resource}${at(ev.x, ev.y)}`);583 case 'starvation':584 return h('div', { class: 'feed-line feed-fate' },585 tick, `${ev.deaths} starved in `, nameSpan(ev.tribe), `'s settlement #${ev.settlementId}`);586 case 'threat_spawned':587 return h('div', { class: 'feed-line' }, tick, `${noun(ev.kind)} appeared${at(ev.x, ev.y)}`);588 case 'threat_cleared':589 return h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), ` cleared a ${noun(ev.kind)}${at(ev.x, ev.y)}`);590 case 'age_advanced':591 return h('div', { class: 'feed-line feed-say' }, tick, nameSpan(ev.tribe),592 ` enters a new age (${['wandering', 'settled', 'crafting', 'high'][ev.age] ?? ev.age})`);593 case 'tech_unlocked':594 return h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), ` unlocked ${noun(ev.tech)}`);595 case 'sighted':596 return h('div', { class: 'feed-line' }, tick, nameSpan(ev.tribe), ' sighted ', nameSpan(ev.enemy), ` near (${ev.x}, ${ev.y})`);597 case 'eliminated':598 return h('div', { class: 'feed-line feed-fate' }, tick, nameSpan(ev.tribe), ' was eliminated');599 case 'forfeited':600 return h('div', { class: 'feed-line feed-fate' }, tick, nameSpan(ev.tribe), ' forfeited');601 case 'order_dropped':602 return h('div', { class: 'feed-line feed-muted' },603 tick, nameSpan(ev.tribe), ` tried to ${ev.order} - ${ev.reason}`);604 }605 // Future event kinds render as a plain note instead of crashing the feed.606 return h('div', { class: 'feed-line feed-muted' }, tick, (ev as { t: string }).t);607 }608609 const trim = () => {610 while (body.childElementCount > maxLines) body.firstElementChild!.remove();611 if (stick) body.scrollTop = body.scrollHeight;612 };613614 return {615 root,616 append(events) {617 if (events.length === 0) return;618 for (const ev of events) body.append(line(ev));619 trim();620 },621 appendLogs(lines) {622 if (lines.length === 0) return;623 // Log text comes from scripts — text nodes only, never innerHTML.624 for (const l of lines) {625 body.append(626 h('div', { class: 'feed-line feed-log' },627 h('span', { class: 'feed-tick' }, `[t${l.t}] `), '» ', l.text),628 );629 }630 trim();631 },632 reset() {633 clear(body);634 stick = true;635 },636 };637}638639// --------------------------------------------------------------------------640641interface TribePanel {642 root: HTMLElement;643 update(frame: MapFrame): void;644 /** Seed the vitals from a stored metrics frame (instant-chronicle mode). */645 prime(m: TribeMetrics): void;646 /** Paint the tribe's Urizen banner portrait once the atlas is loaded. */647 drawPortrait(atlas: UrizenAtlas, slot: number): void;648}649650/**651 * Pull a self-description from a locked script source: an explicit652 * `@description: ...` tag wins; otherwise the first substantive `//` comment653 * line. Untrusted text — rendered via text nodes only.654 */655function scriptDescription(source: string): string {656 const head = source.slice(0, 2000);657 const tagged = /@description:?\s*(.+)/.exec(head);658 if (tagged?.[1]) return tagged[1].trim().slice(0, 160);659 for (const line of head.split('\n')) {660 const m = /^\s*\/\/\s*(.+)$/.exec(line);661 if (!m?.[1]) continue;662 const text = m[1].trim();663 // skip decoration rows and directive-looking lines664 if (text.length < 8 || /^[-=*~_]+$/.test(text) || text.startsWith('@')) continue;665 return text.slice(0, 160);666 }667 return '';668}669670interface IdentityCard {671 root: HTMLElement;672 drawPortrait(atlas: UrizenAtlas, slot: number): void;673}674675/** Chronicle-mode rail card: who is playing, not how they're doing — the676 * numbers already live on the charts. */677function makeIdentityCard(slot: number, name: string, desc: string, ref: string): IdentityCard {678 const color = tribeColor(slot);679 const portrait = h('canvas', { class: 'portrait', width: TILE, height: TILE });680 const root = h(681 'div',682 { class: 'tribe-panel identity-card', style: `border-left-color:${color}` },683 h('div', { class: 'head' },684 portrait,685 h('span', { class: 't-name', style: `color:${color}`, title: name }, name),686 ),687 desc !== '' ? h('p', { class: 'identity-desc' }, desc) : null,688 h('div', { class: 'muted mono identity-ref' }, ref),689 );690 return {691 root,692 drawPortrait(atlas, s) {693 const cx = portrait.getContext('2d');694 if (cx) atlas.draw(cx, `tribe.${s % 8}`, 0, 0, TILE, color);695 },696 };697}698699function makeTribePanel(slot: number, name: string): TribePanel {700 const color = tribeColor(slot);701 const overDot = h('span', { class: 'dot', title: 'over gas budget' });702 const status = h('span', { class: 'muted status-word' }, 'alive');703 const errLine = h('div', { class: 'error-text', style: 'display:none;font-size:0.75rem' });704 const portrait = h('canvas', { class: 'portrait', width: TILE, height: TILE });705706 const stat = (label: string, title: string) => {707 const value = h('span', { class: 'mono' }, '0');708 const el = h('span', { class: 'stat', title }, `${label} `, value);709 return { el, value };710 };711 const pop = stat('pop', 'population');712 const stl = stat('stl', 'settlements');713 const wb = stat('wb', 'warbands');714 const coin = stat('coin', 'coin');715 const score = stat('score', 'headline score');716717 const root = h(718 'div',719 { class: 'tribe-panel', style: `border-left-color:${color}` },720 h('div', { class: 'head' },721 portrait,722 h('span', { class: 't-name', style: `color:${color}`, title: name }, name),723 h('span', { class: 'spacer', style: 'flex:1' }),724 overDot,725 status,726 ),727 h('div', { class: 'stats' }, pop.el, stl.el, wb.el, coin.el, score.el),728 errLine,729 );730731 const applyAlive = (alive: boolean) => {732 status.textContent = alive ? 'alive' : 'fallen';733 status.className = alive ? 'ok-text status-word' : 'error-text status-word';734 root.classList.toggle('dead', !alive);735 };736737 return {738 root,739 drawPortrait(atlas, s) {740 const cx = portrait.getContext('2d');741 if (cx) atlas.draw(cx, `tribe.${s % 8}`, 0, 0, TILE, color);742 },743 prime(m) {744 pop.value.textContent = fmtCompact(m.pop);745 stl.value.textContent = String(m.settlementsByTier.reduce((a, v) => a + v, 0));746 wb.value.textContent = String(m.warbands);747 coin.value.textContent = fmtCompact(m.coin);748 score.value.textContent = fmtCompact(m.score);749 applyAlive(m.alive);750 },751 update(frame: MapFrame) {752 const t = frame.tribes[slot];753 if (!t) return;754 pop.value.textContent = fmtCompact(t.pop);755 stl.value.textContent = String(t.settlements);756 wb.value.textContent = String(t.warbands);757 coin.value.textContent = fmtCompact(t.coin);758 score.value.textContent = fmtCompact(t.score);759 overDot.classList.toggle('over', t.overBudget);760 applyAlive(t.alive);761 if (t.error) {762 errLine.style.display = 'block';763 errLine.textContent = `script error: ${t.error}`;764 }765 },766 };767}768769// --------------------------------------------------------------------------770// Endgame overlay — the "game over" screen: a centered frame over the dimmed771// viewport with victor, reason, score table, and [VIEW CHRONICLE]/[CLOSE].772773function showResult(774 overlay: HTMLElement,775 result: SimResult,776 names: string[],777 atlas: UrizenAtlas | null,778 onViewChronicle: () => void,779) {780 clear(overlay);781 const winnerName = result.winner != null ? (names[result.winner] ?? `tribe ${result.winner}`) : null;782 const sorted = [...result.tribes].sort((a, b) => b.score - a.score);783 const reasonText = result.reason === 'last_standing' ? 'last tribe standing' : 'the age ends — tick cap reached';784785 const title =786 winnerName != null787 ? h('h2', null,788 '✦ ',789 h('span', { style: `color:${tribeColor(result.winner!)}` }, winnerName),790 ' is victorious ✦')791 : h('h2', null, '— stalemate —');792793 const chronicleBtn = h('button', null, '[ view chronicle ]');794 const closeBtn = h('button', null, '[ close ]');795 closeBtn.onclick = () => { overlay.style.display = 'none'; };796 chronicleBtn.onclick = () => {797 overlay.style.display = 'none';798 onViewChronicle();799 };800801 overlay.append(802 h('div', { class: 'result-banner' },803 title,804 h('p', { class: 'result-sub' }, `${reasonText} - tick ${result.endedAt}`),805 h('div', { class: 'result-scroll' }, h('table', { class: 'list' },806 h('thead', null, h('tr', null,807 h('th'), h('th', null, 'Tribe'), h('th', null, 'Score'),808 h('th', null, 'Pop'), h('th', null, 'Stl'), h('th', null, 'Techs'),809 h('th', null, 'Military'), h('th', null, 'Coin'), h('th', null, 'Bldg'),810 h('th', null, 'Casualties dealt'), h('th', null, 'Fate'))),811 h('tbody', null,812 sorted.map((t, rank) => {813 const portrait = h('canvas', {814 width: TILE, height: TILE,815 style: 'width:22px;height:22px;image-rendering:pixelated;vertical-align:middle',816 });817 const cx = portrait.getContext('2d');818 if (atlas && cx) atlas.draw(cx, `tribe.${t.tribeId % 8}`, 0, 0, TILE, tribeColor(t.tribeId));819 return h('tr', null,820 h('td', { class: 'result-rank' }, rank === 0 ? '★' : `${rank + 1}.`),821 h('td', null,822 portrait, ' ',823 h('span', { style: `color:${tribeColor(t.tribeId)}` }, names[t.tribeId] ?? `tribe ${t.tribeId}`),824 ),825 h('td', { class: 'mono' }, String(t.score)),826 h('td', { class: 'mono' }, String(t.scoreBreakdown.pop)),827 h('td', { class: 'mono' }, String(t.scoreBreakdown.settlements)),828 h('td', { class: 'mono' }, String(t.scoreBreakdown.techs)),829 h('td', { class: 'mono' }, String(t.scoreBreakdown.military)),830 h('td', { class: 'mono' }, String(t.scoreBreakdown.coin)),831 h('td', { class: 'mono' }, String(t.scoreBreakdown.buildings)),832 h('td', { class: 'mono' }, String(t.casualtiesDealt)),833 h('td', { class: 'muted' }, t.alive ? 'survived' : `fell at tick ${t.diedAt ?? '?'}`),834 );835 }),836 ),837 )),838 h('div', { class: 'row result-actions' }, chronicleBtn, closeBtn),839 ),840 );841 overlay.style.display = '';842}