- server precomputes full sim metrics at create_sim (waitUntil, 120s CPU limit); share links render the finished chronicle + endgame instantly; first-viewer client fallback POSTs the cache (first-write-only, gzipped, shape-checked); workerd encodeBody:'manual' + D1 blob normalization - UI redesigned as a 1-bit terminal wargame console: VT323, punched-frame HUD panels, CHRONICLE/WORLD mode toggle, segmented game clock, faction panels with Urizen portraits, victory banner, telemetry-styled charts - map: per-cell deterministic terrain variants + decor + tone jitter + shoreline treatment (23 new hand-picked tiles) - world replay is opt-in: WATCH THE REPLAY boots the deterministic runner
- commit
- 87d1b333961f97129aac2471af5804e8817a12e5
- parent
- 759122afa16fae1a82b89c689416e8cf6096e70c
- author
- Dami <dami.etoile@gmail.com>
| A | migrations/ | 10 | + |
| M | package-lock.json | 10 | + |
| M | web/ | 3 | +- |
| M | web/ | 47 | ++ |
| M | web/ | 3 | +- |
| M | web/ | 1 | + |
| M | web/ | 464 | ++++++++++++++++------- |
| M | web/ | 80 | ++-- |
| A | web/ | 264 | +++++++++++++ |
| M | web/ | 29 | +- |
| M | web/ | 627 | +++++++++++++++++++++--------- |
| M | web/ | 54 | +-- |
| M | web/ | 15 | +- |
| M | worker/ | 8 | +- |
| M | worker/ | 54 | ++- |
| M | worker/ | 2 | +- |
| M | worker/ | 10 | + |
| A | worker/ | 134 | ++++++ |
| A | worker/ | 24 | + |
| M | wrangler.jsonc | 2 | + |
@@ -0,0 +1,10 @@
1+-- Precomputed sim metrics: the share link renders finished stats instantly2+-- instead of recomputing the replay. data = gzipped JSON3+-- {frames: MetricsFrame[], result: SimResult, stateHash}.4+CREATE TABLE sim_metrics (5+ sim_id TEXT PRIMARY KEY REFERENCES sims(id) ON DELETE CASCADE,6+ data BLOB NOT NULL,7+ state_hash TEXT NOT NULL,8+ computed_by TEXT NOT NULL CHECK (computed_by IN ('server', 'client')),9+ created_at INTEGER NOT NULL10+);@@ -960,6 +960,15 @@
960960 "node": ">=18"961961 }962962 },963+ "node_modules/@fontsource/vt323": {964+ "version": "5.3.0",965+ "resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.3.0.tgz",966+ "integrity": "sha512-3w33Rg/0+R1587HQfz4t7Q7e0GGeCqr3wmzIwqdGInUnrGEwMA5eX39GP4Z9wpTQnwfhVOhYuxuRwRUeIcicbw==",967+ "license": "OFL-1.1",968+ "funding": {969+ "url": "https://github.com/sponsors/ayuhito"970+ }971+ },963972 "node_modules/@hono/node-server": {964973 "version": "2.1.1",965974 "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",@@ -6013,6 +6022,7 @@
60136022 "web": {60146023 "version": "0.1.0",60156024 "dependencies": {6025+ "@fontsource/vt323": "^5.3.0",60166026 "@tribes/engine": "*",60176027 "@tribes/protocol": "*",60186028 "@tribes/script-host": "*"@@ -1,15 +1,24 @@
11 /** Typed REST client for the routes in @tribes/protocol api.ts. */22 import type {3+ MetricsFrame,34 ScriptRecord,45 ScriptWithSource,56 SimBundle,67 SimRecord,8+ SimResult,79 StarKind,810 TokenRecord,911 UserInfo,1012 ValidationReport,1113 } from '@tribes/protocol';1214 15+/** Precomputed sim outcome served by GET /api/sims/:id/metrics. */16+export interface SimMetricsPayload {17+ frames: MetricsFrame[];18+ result: SimResult;19+ stateHash: string;20+}21+1322 export class ApiError extends Error {1423 constructor(1524 public status: number,@@ -76,6 +85,44 @@ export const api = {
7685 listSims: () => req<SimRecord[]>('GET', '/api/sims'),7786 getSimBundle: (id: string) => req<SimBundle>('GET', `/api/sims/${encodeURIComponent(id)}/bundle`),7887 88+ /**89+ * Precomputed metrics for the instant chronicle. 200 → payload (server90+ * gzips the wire; the browser decompresses transparently); 404 → null91+ * (not computed yet — caller should replay and POST the result).92+ */93+ getSimMetrics: async (id: string): Promise<SimMetricsPayload | null> => {94+ const res = await fetch(`/api/sims/${encodeURIComponent(id)}/metrics`, { credentials: 'same-origin' });95+ if (res.status === 404) return null;96+ if (!res.ok) {97+ let parsed: { code?: string; message?: string } | null = null;98+ try {99+ parsed = (await res.json()) as { code?: string; message?: string };100+ } catch {101+ /* non-JSON error body */102+ }103+ throw new ApiError(res.status, parsed);104+ }105+ return (await res.json()) as SimMetricsPayload;106+ },107+108+ /**109+ * Store the replay outcome so the next viewer is instant. Body is raw110+ * gzipped JSON bytes (CompressionStream) sent with content-type111+ * application/json and NO content-encoding header — the server gunzips112+ * explicitly. First write wins; later writes are no-ops server-side.113+ */114+ postSimMetrics: async (id: string, payload: SimMetricsPayload): Promise<void> => {115+ const gz = new Blob([JSON.stringify(payload)]).stream().pipeThrough(new CompressionStream('gzip'));116+ const body = await new Response(gz).arrayBuffer();117+ const res = await fetch(`/api/sims/${encodeURIComponent(id)}/metrics`, {118+ method: 'POST',119+ headers: { 'content-type': 'application/json' },120+ body,121+ credentials: 'same-origin',122+ });123+ if (!res.ok) throw new ApiError(res.status, null);124+ },125+79126 // tokens80127 mintToken: (name: string) => req<TokenRecord>('POST', '/api/tokens', { name }),81128 listTokens: () => req<TokenRecord[]>('GET', '/api/tokens'),@@ -1,4 +1,5 @@
11 /** App entry: shell + History-based router ('/' home, '/s/:id' public viewer). */2+import '@fontsource/vt323'; // pixel/terminal display face for the whole HUD23 import './style.css';34 import type { UserInfo } from '@tribes/protocol';45 import { h, clear } from './dom';@@ -41,7 +42,7 @@ function topbar(): HTMLElement {
4142 'div',4243 { class: 'topbar' },4344 h('a', { class: 'brand', href: '/', onclick: (e: Event) => { e.preventDefault(); navigate('/'); } }, 'TRIBES'),44- h('span', { class: 'muted' }, 'strategy-script fishbowl'),45+ h('span', { class: 'muted' }, '- a strategy-script fishbowl -'),4546 h('span', { class: 'spacer' }),4647 currentUser4748 ? h(@@ -269,6 +269,7 @@ export function createDashboard(names: string[]): Dashboard {
269269 }270270 if (leader) {271271 kLeader.value.textContent = name(leader.tribeId);272+ kLeader.value.style.color = tribeColor(leader.tribeId); // name IS the identity272273 kLeader.sub.textContent = `score ${fmtCompact(leader.score)}`;273274 }274275 kPop.value.textContent = fmtCompact(popSum);@@ -1,18 +1,30 @@
11 /**2- * Public sim viewer (/s/:simId) — fetches the SimBundle and replays it3- * deterministically in a Web Worker. STATS-FIRST: the default tab is a4- * metrics dashboard; the tile map lives behind the optional Watch tab5- * (map frames only stream while watching). ?mock=1 uses the local dev-mock6- * engine (and a fabricated bundle when the API is unavailable).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.717 */8-import type { SimBundle, SimEvent, SimResult } from '@tribes/protocol';18+import type { MetricsFrame, SimBundle, SimEvent, SimResult, TribeMetrics } from '@tribes/protocol';919 import { LIMITS } from '@tribes/protocol';10-import { api } from '../api';20+import { api, type SimMetricsPayload } from '../api';1121 import { h, clear } from '../dom';22+import { log } from '../log';1223 import type { AppCtx } from '../main';1324 import type { MapFrame, LogLine } from '../sim/frames';1425 import { SimRunner, type Speed } from '../sim/runner';1526 import { createMapView } from '../render/map-view';27+import { loadUrizen, TILE, type UrizenAtlas } from '../render/urizen';1628 import { createDashboard } from './dashboard';1729 import { tribeColor } from '../viz/palette';1830 import { fmtCompact } from '../viz/charts';@@ -47,12 +59,24 @@ export function renderViewer(main: HTMLElement, simId: string, ctx: AppCtx): ()
4759 let cleanup: (() => void) | null = null;4860 let disposed = false;4961 50- main.append(h('p', { class: 'notice' }, `Loading sim ${simId}…`));62+ main.append(h('p', { class: 'notice' }, `▚▞ loading sim ${simId} …`));5163 5264 void (async () => {5365 let bundle: SimBundle;66+ let stored: SimMetricsPayload | null = null;5467 try {55- bundle = mock ? mockBundle(simId) : await api.getSimBundle(simId);68+ if (mock) {69+ bundle = mockBundle(simId);70+ } else {71+ // Metrics first: a stored payload makes the chronicle instant.72+ [bundle, stored] = await Promise.all([73+ api.getSimBundle(simId),74+ api.getSimMetrics(simId).catch((err) => {75+ log.warn('metrics fetch failed — falling back to live replay', err);76+ return null;77+ }),78+ ]);79+ }5680 } catch (err) {5781 if (disposed) return;5882 clear(main);@@ -66,7 +90,7 @@ export function renderViewer(main: HTMLElement, simId: string, ctx: AppCtx): ()
6690 return;6791 }6892 if (disposed) return;69- cleanup = buildViewer(main, bundle, mock, ctx);93+ cleanup = buildViewer(main, bundle, mock, stored, ctx);7094 })();7195 7296 return () => {@@ -75,199 +99,329 @@ export function renderViewer(main: HTMLElement, simId: string, ctx: AppCtx): ()
7599 };76100 }77101 78-type Tab = 'dash' | 'watch';102+type Mode = 'chronicle' | 'world';79103 80-function buildViewer(main: HTMLElement, bundle: SimBundle, mock: boolean, _ctx: AppCtx): () => void {104+function buildViewer(105+ main: HTMLElement,106+ bundle: SimBundle,107+ mock: boolean,108+ stored: SimMetricsPayload | null,109+ _ctx: AppCtx,110+): () => void {81111 clear(main);112+ main.classList.add('game-screen');82113 const { sim } = bundle;83114 const mapSize = sim.config.mapSize;115+ const maxTicks = sim.config.maxTicks;84116 const names = sim.scripts.map((s) => s.scriptName);117+ const tickPad = String(maxTicks).length;118+ const fmtTick = (t: number) => `T ${String(t).padStart(tickPad, '0')}/${maxTicks}`;85119 86- // --- DOM scaffold ---87- const banner = h('div');88- const playBtn = h('button', { class: 'primary' }, 'Pause');120+ // ---- HUD status bar -----------------------------------------------------121+ const playBtn = h('button', { disabled: true }, 'pause');89122 const speedBtns = ([1, 4, 16, 'max'] as Speed[]).map((s) =>90- h('button', { class: `small${s === 4 ? ' active' : ''}`, dataset: { speed: String(s) } }, s === 'max' ? 'max' : `${s}×`),123+ h('button', { class: 'small', disabled: true, dataset: { speed: String(s) } }, s === 'max' ? 'max' : `${s}×`),91124 );92- const restartBtn = h('button', { class: 'small' }, 'Restart');93- const tickLabel = h('span', { class: 'mono muted' }, 'tick 0');125+ const restartBtn = h('button', { class: 'small', disabled: true }, 'restart');126+ const tickLabel = h('span', { class: 'tick-label' }, fmtTick(0));94127 const progressFill = h('div');95128 const progress = h('div', { class: 'progress-bar' }, progressFill);96- const hashLine = h('p', { class: 'mono muted', style: 'font-size:0.75rem' });97129 98- // tabs99- const dashTabBtn = h('button', { class: 'tab active' }, 'Dashboard');100- const watchTabBtn = h('button', { class: 'tab' }, 'Watch');101- const tabBar = h('div', { class: 'tab-bar' }, dashTabBtn, watchTabBtn);130+ const chronicleBtn = h('button', { class: 'mode-btn active' }, 'chronicle');131+ const worldBtn = h('button', { class: 'mode-btn' }, 'world');132+133+ const statusBar = h('header', { class: 'hud-status' },134+ h('div', { class: 'hud-ident' },135+ h('h1', null, `sim ${sim.id.replace(/^sim_/, '')}`),136+ h('span', { class: 'sub' }, `seed ${sim.seed} - ${sim.config.tribeCount} tribes - map ${mapSize}`),137+ mock ? h('span', { class: 'hud-mock' }, '[mock engine]') : null,138+ ),139+ h('div', { class: 'mode-toggle' }, chronicleBtn, worldBtn),140+ h('div', { class: 'transport' }, playBtn, ...speedBtns, restartBtn),141+ h('div', { class: 'hud-clock' }, progress, tickLabel),142+ );143+144+ const banner = h('div');145+ const hashLine = h('p', { class: 'hash-line' });102146 103- // dashboard pane147+ // ---- chronicle viewport -------------------------------------------------104148 const dashboard = createDashboard(names);105- const dashPane = h('div', { class: 'tab-pane' }, dashboard.el);149+ const chroniclePane = h('div', { class: 'viewport' },150+ h('span', { class: 'viewport-label' }, 'chronicle'),151+ dashboard.el,152+ );106153 107- // watch pane — map view is owned by render/map-view; we own POV + zoom108- const zoomBtn = h('button', { class: 'small' }, 'Zoom 1:1');154+ // ---- world viewport (map view owned by render/map-view) -----------------155+ const zoomBtn = h('button', { class: 'small' }, 'zoom 1:1');109156 const povSel = h(110157 'select',111158 null,112159 h('option', { value: '-1' }, 'POV: omniscient'),113160 sim.scripts.map((s) => h('option', { value: String(s.slot) }, `POV: ${s.scriptName} (slot ${s.slot})`)),114161 );162+ const worldToolbar = h('div', { class: 'row world-toolbar' }, povSel, zoomBtn);115163 const mapHost = h('div', { class: 'map-host' });116- const watchPane = h('div', { class: 'tab-pane', style: 'display:none' },117- h('div', { class: 'row', style: 'margin-bottom:0.5rem' }, povSel, zoomBtn),164+ const mapView = createMapView(mapHost, { mapSize, names });165+166+ // replay invitation — shown in instant mode until the runner boots167+ const inviteGlyphs = h('div', { class: 'glyphs' });168+ const replayBtn = h('button', { class: 'replay-btn' }, '▶ watch the replay');169+ const replayInvite = h('div', { class: 'replay-invite', style: 'display:none' },170+ inviteGlyphs,171+ replayBtn,172+ h('div', { class: 'hint' },173+ 'The chronicle is already written. Replay the age tick by tick — deterministic, exactly as it happened.'),174+ );175+176+ const worldPane = h('div', { class: 'viewport', style: 'display:none' },177+ h('span', { class: 'viewport-label' }, 'world'),178+ worldToolbar,118179 mapHost,180+ replayInvite,119181 );120- const mapView = createMapView(mapHost, { mapSize, names });121182 122- // side column: per-tribe panels (stats + compact feed) + world feed183+ // ---- side rail: faction panels + world log ------------------------------123184 const panels = sim.scripts.map((s) => makeTribePanel(s.slot, s.scriptName));124185 const tribeFeeds = sim.scripts.map(() => makeEventFeed(names, { compact: true }));125186 for (const [i, p] of panels.entries()) p.root.append(tribeFeeds[i]!.root);126- const worldFeed = makeEventFeed(names, { title: 'World' });187+ const worldFeed = makeEventFeed(names, { title: 'world log' });188+ worldFeed.root.classList.add('world-log');127189 128190 main.append(129- h('div', { class: 'row', style: 'margin-bottom:0.6rem' },130- h('h1', null, `Sim ${sim.id}`),131- h('span', { class: 'muted mono' }, `seed ${sim.seed} - ${sim.config.tribeCount} tribes - map ${mapSize}`),132- mock ? h('span', { class: 'error-text' }, '[mock engine]') : null,133- ),191+ statusBar,134192 banner,135- h('div', { class: 'row viewer-toprow' },136- tabBar,137- h('div', { class: 'sim-controls' },138- playBtn, ...speedBtns, restartBtn,139- h('div', { class: 'progress-wrap' }, progress, tickLabel),140- ),141- ),142- h('div', { class: 'viewer-layout' },143- h('div', { class: 'viewer-main' }, dashPane, watchPane, hashLine),144- h('div', { class: 'viewer-side' }, ...panels.map((p) => p.root), worldFeed.root),193+ h('div', { class: 'game-layout' },194+ h('div', { class: 'game-main' }, chroniclePane, worldPane, worldFeed.root, hashLine),195+ h('div', { class: 'game-side' }, ...panels.map((p) => p.root)),145196 ),146197 );147198 148- // --- frame plumbing ---199+ // ---- Urizen portraits (shared atlas; draws when loaded) -----------------200+ let atlas: UrizenAtlas | null = null;201+ void loadUrizen()202+ .then((a) => {203+ atlas = a;204+ for (const [slot, p] of panels.entries()) p.drawPortrait(a, slot);205+ // flavor row on the replay invite: the rival banners, side by side206+ clear(inviteGlyphs);207+ for (let slot = 0; slot < names.length; slot++) {208+ const c = h('canvas', { width: TILE, height: TILE, style: 'width:36px;height:36px' });209+ const cx = c.getContext('2d');210+ if (cx) a.draw(cx, `tribe.${slot % 8}`, 0, 0, TILE, tribeColor(slot));211+ inviteGlyphs.append(c);212+ }213+ })214+ .catch((err) => log.warn('urizen atlas failed to load (portraits skipped)', err));215+216+ // ---- state --------------------------------------------------------------217+ let runner: SimRunner | null = null;149218 let latestFrame: MapFrame | null = null;150219 let mapDirty = false;151220 let dashDirty = false;152- let tab: Tab = 'dash';153-154- const runner = new SimRunner(bundle, mock, {155- onFrame(frame) {156- latestFrame = frame;157- if (frame.hasMap) mapDirty = true;158- // Append here (not in the paint loop): frames can arrive faster than159- // paints, and each frame's batch must reach the feeds exactly once.160- worldFeed.append(frame.world);161- for (const [slot, tf] of tribeFeeds.entries()) {162- tf.append(frame.eventsByTribe[slot] ?? []);163- tf.appendLogs(frame.logsByTribe[slot] ?? []);164- }165- },166- onMetrics(frame) {167- dashboard.push(frame);168- dashDirty = true;169- },170- onDone(result, stateHash) {171- playBtn.textContent = 'Play';172- playBtn.disabled = true;173- showResult(banner, result, names);174- hashLine.textContent = `final state hash ${stateHash}`;175- dashDirty = true; // flush the final metrics frame into the charts176- },177- onFatal(error) {178- playBtn.disabled = true;179- clear(banner);180- banner.append(181- h('div', { class: 'result-banner' },182- h('h2', null, 'Sim failed'),183- h('p', { class: 'error-text', style: 'white-space:pre-wrap' }, error),184- mock ? null : h('p', { class: 'muted' }, 'The engine/script-host packages may not be integrated yet — try ?mock=1.'),185- ),186- );187- },188- });221+ let mode: Mode = 'chronicle';222+ const instant = stored != null;223+ const postNeeded = !mock && stored == null;224+ let posted = false;225+ /** Frames accumulated for the first-write POST (dedup like the dashboard). */226+ const metricsHistory: MetricsFrame[] = [];227+228+ const setTransportEnabled = (on: boolean) => {229+ playBtn.disabled = !on;230+ restartBtn.disabled = !on;231+ for (const b of speedBtns) b.disabled = !on;232+ };233+ const markSpeed = (s: Speed) => {234+ for (const b of speedBtns) b.classList.toggle('active', b.dataset.speed === String(s));235+ };236+237+ const showEnd = (result: SimResult, stateHash: string) => {238+ showResult(banner, result, names, atlas);239+ hashLine.textContent = `final state hash ${stateHash}`;240+ tickLabel.textContent = fmtTick(result.endedAt);241+ progressFill.style.width = `${((result.endedAt / maxTicks) * 100).toFixed(2)}%`;242+ };189243 190- // render loop (decoupled from sim rate; charts and map redraw rAF-throttled)244+ // ---- instant chronicle (stored metrics) ---------------------------------245+ if (stored) {246+ for (const f of stored.frames) dashboard.push(f);247+ dashDirty = true;248+ showEnd(stored.result, stored.stateHash);249+ // faction panels open on their FINAL vitals, not zeros250+ const lastFrame = stored.frames[stored.frames.length - 1];251+ if (lastFrame) for (const m of lastFrame.tribes) panels[m.tribeId]?.prime(m);252+ replayInvite.style.display = '';253+ mapHost.style.display = 'none';254+ worldToolbar.style.display = 'none';255+ }256+257+ // ---- runner (booted lazily in instant mode) -----------------------------258+ const makeRunner = () =>259+ new SimRunner(bundle, mock, {260+ onFrame(frame) {261+ latestFrame = frame;262+ if (frame.hasMap) mapDirty = true;263+ // Append here (not in the paint loop): frames can arrive faster than264+ // paints, and each frame's batch must reach the feeds exactly once.265+ worldFeed.append(frame.world);266+ for (const [slot, tf] of tribeFeeds.entries()) {267+ tf.append(frame.eventsByTribe[slot] ?? []);268+ tf.appendLogs(frame.logsByTribe[slot] ?? []);269+ }270+ },271+ onMetrics(frame) {272+ dashboard.push(frame);273+ dashDirty = true;274+ if (postNeeded) {275+ const last = metricsHistory[metricsHistory.length - 1];276+ if (!last || frame.tick > last.tick) metricsHistory.push(frame);277+ }278+ },279+ onDone(result, stateHash) {280+ playBtn.textContent = 'play';281+ playBtn.disabled = true;282+ showEnd(result, stateHash);283+ dashDirty = true; // flush the final metrics frame into the charts284+ if (postNeeded && !posted) {285+ posted = true;286+ void api287+ .postSimMetrics(sim.id, { frames: metricsHistory, result, stateHash })288+ .then(() => log.info('metrics stored — next viewer is instant'))289+ .catch((err) => log.warn('metrics store failed (next viewer will re-simulate)', err));290+ }291+ },292+ onFatal(error) {293+ playBtn.disabled = true;294+ clear(banner);295+ banner.append(296+ h('div', { class: 'result-banner failed' },297+ h('h2', null, '✕ sim failed ✕'),298+ h('p', { class: 'error-text', style: 'white-space:pre-wrap' }, error),299+ mock ? null : h('p', { class: 'muted' }, 'The engine/script-host packages may not be integrated yet — try ?mock=1.'),300+ ),301+ );302+ },303+ });304+305+ const bootRunner = (speed: Speed) => {306+ if (runner) return;307+ runner = makeRunner();308+ runner.setSpeed(speed);309+ markSpeed(speed);310+ setTransportEnabled(true);311+ playBtn.textContent = 'pause';312+ // hide the invite; the map takes over313+ replayInvite.style.display = 'none';314+ mapHost.style.display = '';315+ worldToolbar.style.display = '';316+ runner.setPov(Number(povSel.value));317+ runner.setWatch(mode === 'world');318+ runner.start();319+ runner.playing = true;320+ };321+322+ if (mock) {323+ bootRunner(4);324+ } else if (!instant) {325+ // No precomputed metrics: simulate the world at MAX speed, then store it.326+ banner.append(327+ h('div', { class: 'simulating-note' },328+ '▚ simulating world — the chronicle writes itself ',329+ h('span', { class: 'blink' }, '█'),330+ ),331+ );332+ bootRunner('max');333+ }334+335+ replayBtn.onclick = () => bootRunner(4);336+337+ // ---- render loop (rAF-throttled; decoupled from sim rate) ---------------191338 let raf = 0;192339 const paint = () => {193340 raf = requestAnimationFrame(paint);194- if (dashDirty && tab === 'dash') {341+ if (dashDirty && mode === 'chronicle') {195342 dashboard.render();196343 dashDirty = false;197344 }198- if (mapDirty && tab === 'watch' && latestFrame && latestFrame.hasMap) {345+ if (mapDirty && mode === 'world' && latestFrame && latestFrame.hasMap) {199346 mapView.draw(latestFrame);200347 mapDirty = false;201348 }202- if (latestFrame) {203- tickLabel.textContent = `tick ${latestFrame.tick} / ${latestFrame.maxTicks}`;349+ if (latestFrame && runner && !runner.finished) {350+ tickLabel.textContent = fmtTick(latestFrame.tick);204351 progressFill.style.width = `${((latestFrame.tick / latestFrame.maxTicks) * 100).toFixed(2)}%`;205- for (const p of panels) p.update(latestFrame);206352 }353+ if (latestFrame) for (const p of panels) p.update(latestFrame);207354 };208355 raf = requestAnimationFrame(paint);209356 210- // --- tabs ---211- const setTab = (t: Tab) => {212- if (tab === t) return;213- tab = t;214- dashTabBtn.classList.toggle('active', t === 'dash');215- watchTabBtn.classList.toggle('active', t === 'watch');216- dashPane.style.display = t === 'dash' ? '' : 'none';217- watchPane.style.display = t === 'watch' ? '' : 'none';357+ // ---- mode toggle --------------------------------------------------------358+ const setMode = (m: Mode) => {359+ if (mode === m) return;360+ mode = m;361+ chronicleBtn.classList.toggle('active', m === 'chronicle');362+ worldBtn.classList.toggle('active', m === 'world');363+ chroniclePane.style.display = m === 'chronicle' ? '' : 'none';364+ worldPane.style.display = m === 'world' ? '' : 'none';218365 // Map frames only stream while watching.219- runner.setWatch(t === 'watch');220- if (t === 'dash') dashDirty = true;366+ runner?.setWatch(m === 'world');367+ if (m === 'chronicle') dashDirty = true;221368 };222- dashTabBtn.onclick = () => setTab('dash');223- watchTabBtn.onclick = () => setTab('watch');369+ chronicleBtn.onclick = () => setMode('chronicle');370+ worldBtn.onclick = () => setMode('world');224371 225- // --- controls ---372+ // ---- transport ----------------------------------------------------------226373 playBtn.onclick = () => {374+ if (!runner) return;227375 runner.setPlaying(!runner.playing);228- playBtn.textContent = runner.playing ? 'Pause' : 'Play';376+ playBtn.textContent = runner.playing ? 'pause' : 'play';229377 };230378 for (const btn of speedBtns) {231379 btn.onclick = () => {380+ if (!runner) return;232381 const v = btn.dataset.speed!;233382 runner.setSpeed(v === 'max' ? 'max' : (Number(v) as Speed));234- speedBtns.forEach((b) => b.classList.toggle('active', b === btn));383+ markSpeed(runner.speed);235384 };236385 }237386 restartBtn.onclick = () => {238- clear(banner);387+ if (!runner) return;239388 hashLine.textContent = '';240389 latestFrame = null;241390 playBtn.disabled = false;242- playBtn.textContent = 'Pause';391+ playBtn.textContent = 'pause';243392 worldFeed.reset();244393 for (const tf of tribeFeeds) tf.reset();245- dashboard.reset();394+ if (!instant) {395+ // live-run page: the chronicle rebuilds with the replay396+ clear(banner);397+ dashboard.reset();398+ metricsHistory.length = 0;399+ }400+ // instant page: the chronicle and endgame banner are already final — keep401+ // them; dashboard.push() dedupes the re-run's frames.246402 dashDirty = true;247403 runner.setPov(runner.pov); // keep pov248404 runner.restart(); // start() re-sends the watch flag249405 };250406 zoomBtn.onclick = () => {251407 const zoomed = mapHost.classList.toggle('zoomed');252- zoomBtn.textContent = zoomed ? 'Zoom fit' : 'Zoom 1:1';408+ zoomBtn.textContent = zoomed ? 'zoom fit' : 'zoom 1:1';253409 };254410 povSel.onchange = () => {255411 // POV only affects the map — feeds are per-tribe channels.256- runner.setPov(Number(povSel.value));412+ runner?.setPov(Number(povSel.value));257413 };258414 259- // --- map hover tooltips (owned by the map view; frame supplied by us) ---415+ // ---- map hover tooltips (owned by the map view; frame supplied by us) ---260416 const mapTips = mapView.attachTooltips(() => (latestFrame && latestFrame.hasMap ? latestFrame : null));261417 262- runner.start();263- runner.playing = true;264-265418 return () => {419+ main.classList.remove('game-screen');266420 cancelAnimationFrame(raf);267421 mapTips.dispose();268422 mapView.dispose();269423 dashboard.dispose();270- runner.dispose();424+ runner?.dispose();271425 };272426 }273427 @@ -420,13 +574,18 @@ function makeEventFeed(names: string[], opts: FeedOptions = {}): EventFeed {
420574 interface TribePanel {421575 root: HTMLElement;422576 update(frame: MapFrame): void;577+ /** Seed the vitals from a stored metrics frame (instant-chronicle mode). */578+ prime(m: TribeMetrics): void;579+ /** Paint the tribe's Urizen banner portrait once the atlas is loaded. */580+ drawPortrait(atlas: UrizenAtlas, slot: number): void;423581 }424582 425583 function makeTribePanel(slot: number, name: string): TribePanel {426584 const color = tribeColor(slot);427585 const overDot = h('span', { class: 'dot', title: 'over gas budget' });428- const status = h('span', { class: 'muted' }, 'alive');586+ const status = h('span', { class: 'muted status-word' }, 'alive');429587 const errLine = h('div', { class: 'error-text', style: 'display:none;font-size:0.75rem' });588+ const portrait = h('canvas', { class: 'portrait', width: TILE, height: TILE });430589 431590 const stat = (label: string, title: string) => {432591 const value = h('span', { class: 'mono' }, '0');@@ -443,8 +602,8 @@ function makeTribePanel(slot: number, name: string): TribePanel {
443602 'div',444603 { class: 'tribe-panel', style: `border-left-color:${color}` },445604 h('div', { class: 'head' },446- h('span', { class: 'legend-key', style: `background:${color}` }),447- h('span', null, name),605+ portrait,606+ h('span', { style: `color:${color}` }, name),448607 h('span', { class: 'spacer', style: 'flex:1' }),449608 overDot,450609 status,@@ -453,8 +612,26 @@ function makeTribePanel(slot: number, name: string): TribePanel {
453612 errLine,454613 );455614 615+ const applyAlive = (alive: boolean) => {616+ status.textContent = alive ? 'alive' : 'fallen';617+ status.className = alive ? 'ok-text status-word' : 'error-text status-word';618+ root.classList.toggle('dead', !alive);619+ };620+456621 return {457622 root,623+ drawPortrait(atlas, s) {624+ const cx = portrait.getContext('2d');625+ if (cx) atlas.draw(cx, `tribe.${s % 8}`, 0, 0, TILE, color);626+ },627+ prime(m) {628+ pop.value.textContent = fmtCompact(m.pop);629+ stl.value.textContent = String(m.settlementsByTier.reduce((a, v) => a + v, 0));630+ wb.value.textContent = String(m.warbands);631+ coin.value.textContent = fmtCompact(m.coin);632+ score.value.textContent = fmtCompact(m.score);633+ applyAlive(m.alive);634+ },458635 update(frame: MapFrame) {459636 const t = frame.tribes[slot];460637 if (!t) return;@@ -464,9 +641,7 @@ function makeTribePanel(slot: number, name: string): TribePanel {
464641 coin.value.textContent = fmtCompact(t.coin);465642 score.value.textContent = fmtCompact(t.score);466643 overDot.classList.toggle('over', t.overBudget);467- status.textContent = t.alive ? 'alive' : 'dead';468- status.className = t.alive ? 'ok-text' : 'error-text';469- root.classList.toggle('dead', !t.alive);644+ applyAlive(t.alive);470645 if (t.error) {471646 errLine.style.display = 'block';472647 errLine.textContent = `script error: ${t.error}`;@@ -475,29 +650,46 @@ function makeTribePanel(slot: number, name: string): TribePanel {
475650 };476651 }477652 478-function showResult(banner: HTMLElement, result: SimResult, names: string[]) {653+// --------------------------------------------------------------------------654+// Endgame banner — the "game over" screen: victor, reason, score table.655+656+function showResult(banner: HTMLElement, result: SimResult, names: string[], atlas: UrizenAtlas | null) {479657 clear(banner);480658 const winnerName = result.winner != null ? (names[result.winner] ?? `tribe ${result.winner}`) : null;481659 const sorted = [...result.tribes].sort((a, b) => b.score - a.score);660+ const reasonText = result.reason === 'last_standing' ? 'last tribe standing' : 'the age ends — tick cap reached';661+662+ const title =663+ winnerName != null664+ ? h('h2', null,665+ '✦ ',666+ h('span', { style: `color:${tribeColor(result.winner!)}` }, winnerName),667+ ' is victorious ✦')668+ : h('h2', null, '— stalemate —');669+482670 banner.append(483671 h('div', { class: 'result-banner' },484- h('h2', null,485- winnerName != null ? `${winnerName} wins` : 'Tie',486- h('span', { class: 'muted', style: 'font-weight:400;font-size:0.8rem' },487- ` (${result.reason === 'last_standing' ? 'last tribe standing' : 'tick cap reached'} at tick ${result.endedAt})`),488- ),672+ title,673+ h('p', { class: 'result-sub' }, `${reasonText} - tick ${result.endedAt}`),489674 h('table', { class: 'list' },490675 h('thead', null, h('tr', null,491- h('th', null, 'Tribe'), h('th', null, 'Score'),676+ h('th'), h('th', null, 'Tribe'), h('th', null, 'Score'),492677 h('th', null, 'Pop'), h('th', null, 'Stl'), h('th', null, 'Techs'),493678 h('th', null, 'Military'), h('th', null, 'Coin'), h('th', null, 'Bldg'),494679 h('th', null, 'Casualties dealt'), h('th', null, 'Fate'))),495680 h('tbody', null,496- sorted.map((t) =>497- h('tr', null,681+ sorted.map((t, rank) => {682+ const portrait = h('canvas', {683+ width: TILE, height: TILE,684+ style: 'width:22px;height:22px;image-rendering:pixelated;vertical-align:middle',685+ });686+ const cx = portrait.getContext('2d');687+ if (atlas && cx) atlas.draw(cx, `tribe.${t.tribeId % 8}`, 0, 0, TILE, tribeColor(t.tribeId));688+ return h('tr', null,689+ h('td', { class: 'result-rank' }, rank === 0 ? '★' : `${rank + 1}.`),498690 h('td', null,499- h('span', { class: 'legend-key', style: `background:${tribeColor(t.tribeId)};margin-right:0.4rem` }),500- names[t.tribeId] ?? `tribe ${t.tribeId}`,691+ portrait, ' ',692+ h('span', { style: `color:${tribeColor(t.tribeId)}` }, names[t.tribeId] ?? `tribe ${t.tribeId}`),501693 ),502694 h('td', { class: 'mono' }, String(t.score)),503695 h('td', { class: 'mono' }, String(t.scoreBreakdown.pop)),@@ -507,9 +699,9 @@ function showResult(banner: HTMLElement, result: SimResult, names: string[]) {
507699 h('td', { class: 'mono' }, String(t.scoreBreakdown.coin)),508700 h('td', { class: 'mono' }, String(t.scoreBreakdown.buildings)),509701 h('td', { class: 'mono' }, String(t.casualtiesDealt)),510- h('td', { class: 'muted' }, t.alive ? 'survived' : `died at tick ${t.diedAt ?? '?'}`),511- ),512- ),702+ h('td', { class: 'muted' }, t.alive ? 'survived' : `fell at tick ${t.diedAt ?? '?'}`),703+ );704+ }),513705 ),514706 ),515707 ),@@ -12,35 +12,16 @@ import type { ThreatKind, WarbandKind } from '@tribes/protocol';
1212 import { log } from '../log';1313 import { tribeColor } from '../viz/palette';1414 import { loadUrizen, TILE, type UrizenAtlas } from './urizen';15-16-interface TerrainStyle {17- bg: string;18- glyph?: string;19- tint?: string;20- label: string;21-}22-23-/** Fog fallback for unknown chars (and '?'). */24-const FOG: TerrainStyle = { bg: '#000000', label: 'unexplored' };25-26-/** Background fill + manifest glyph (+ optional tint) per TerrainChar. */27-const TERRAIN: Record<string, TerrainStyle> = {28- '?': FOG,29- // Busy full-map glyphs (grass/hills/steppe/arctic) are tinted muted so30- // settlements/warbands stay the brightest things on screen.31- '.': { bg: '#101a0e', glyph: 'terrain.plains', tint: '#3d5c31', label: 'plains' },32- f: { bg: '#0b140a', glyph: 'terrain.forest', label: 'forest' },33- h: { bg: '#131a0f', glyph: 'terrain.hills', tint: '#5b7d43', label: 'hills' },34- m: { bg: '#15151a', glyph: 'terrain.mountain', label: 'mountain' },35- w: { bg: '#081831', glyph: 'terrain.water', label: 'water' },36- r: { bg: '#0c2547', glyph: 'terrain.river', tint: '#7ec8ff', label: 'river' },37- s: { bg: '#0e1710', glyph: 'terrain.swamp', label: 'swamp' },38- t: { bg: '#1a1409', glyph: 'terrain.steppe', tint: '#8f6c30', label: 'steppe' },39- a: { bg: '#161b20', glyph: 'terrain.arctic', tint: '#aebfcc', label: 'arctic' },40- v: { bg: '#1d0d08', glyph: 'terrain.volcano', label: 'volcano' },41- O: { bg: '#15151a', glyph: 'terrain.ore', tint: '#aeb9c9', label: 'ore vein' },42- G: { bg: '#15151a', glyph: 'terrain.gold', tint: '#e8bf45', label: 'gold vein' },43-};15+import {16+ cellHash,17+ FOG,18+ isLandChar,19+ pickDecor,20+ pickVariant,21+ SHORE,22+ stepOf,23+ TERRAIN,24+} from './terrain-style';4425 4526 const TIER_GLYPH = ['settlement.camp', 'settlement.village', 'settlement.town', 'settlement.city'] as const;4627 const TIER_LABEL = ['camp', 'village', 'town', 'city'] as const;@@ -118,6 +99,19 @@ export function createMapView(
11899 (err) => log.error('urizen atlas failed to load', err),119100 );120101 102+ /** True when the water cell at (tx, ty) touches land on a 4-neighbor side. */103+ function nearLand(terrain: string, tx: number, ty: number): boolean {104+ return (105+ (tx > 0 && isLandChar(terrain[ty * mapSize + tx - 1] ?? '?')) ||106+ (tx < mapSize - 1 && isLandChar(terrain[ty * mapSize + tx + 1] ?? '?')) ||107+ (ty > 0 && isLandChar(terrain[(ty - 1) * mapSize + tx] ?? '?')) ||108+ (ty < mapSize - 1 && isLandChar(terrain[(ty + 1) * mapSize + tx] ?? '?'))109+ );110+ }111+112+ // All per-cell choices below (background step, variant, decor, tint step) are113+ // pure functions of cellHash(tx, ty), so this cached layer is deterministic:114+ // it only runs when the terrain string changes and always redraws the same.121115 function renderTerrain(terrain: string): void {122116 const key = `${atlas ? 1 : 0}|${terrain}`;123117 if (key === terrainKey) return;@@ -128,11 +122,31 @@ export function createMapView(
128122 for (let tx = 0; tx < mapSize; tx++) {129123 const ch = terrain[ty * mapSize + tx] ?? '?';130124 const t = TERRAIN[ch] ?? FOG;131- if (t.bg !== '#000000') {132- tctx.fillStyle = t.bg;133- tctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);125+ if (t === FOG) continue; // stays the black clear fill126+ const h = cellHash(tx, ty);127+ let bg: string;128+ let glyph: string | undefined;129+ let tint: string | undefined;130+ if (ch === 'w' && nearLand(terrain, tx, ty)) {131+ // Shoreline: lighter water + foam-tinted ripples so coasts read.132+ bg = stepOf(SHORE.bg, h, 0);133+ glyph = 'terrain.water';134+ tint = stepOf(SHORE.tints, h, 24);135+ } else {136+ bg = stepOf(t.bg, h, 0);137+ const decor = pickDecor(t, h);138+ if (decor) {139+ glyph = decor.glyph;140+ tint = decor.tint;141+ } else {142+ const v = pickVariant(t, h);143+ glyph = v.glyph;144+ if (v.tints) tint = stepOf(v.tints, h, 24);145+ }134146 }135- if (atlas && t.glyph) atlas.draw(tctx, t.glyph, tx * TILE, ty * TILE, TILE, t.tint);147+ tctx.fillStyle = bg;148+ tctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);149+ if (atlas && glyph) atlas.draw(tctx, glyph, tx * TILE, ty * TILE, TILE, tint);136150 }137151 }138152 }@@ -0,0 +1,264 @@
1+/**2+ * DOM-free terrain rendering config for the Urizen map view: per-biome3+ * background palette, weighted glyph variants, sparse decor sets, and the4+ * deterministic per-cell hash that picks between them (Caves-of-Qud-style5+ * per-cell variation so painted regions don't read as repeated wallpaper).6+ *7+ * Kept free of browser APIs on purpose so node scripts can import it directly8+ * (node's TS type-stripping) to mock-render the map for visual verification.9+ *10+ * Determinism: everything a cell draws is a pure function of (x, y) and the11+ * terrain char, so the cached terrain layer renders identically on every pass.12+ * Tint quantization: each variant exposes at most 3 tint strings (dim / base /13+ * bright), which bounds the atlas tint cache to <= 3 entries per variant.14+ */15+import type { UrizenName } from './urizen-manifest';16+17+/** Three quantized brightness steps (dim / base / bright) of one color. */18+export type Steps3 = readonly [string, string, string];19+20+export interface TerrainVariant {21+ /** Manifest glyph; undefined draws only the background (a calm/bare cell). */22+ glyph?: UrizenName;23+ /** Quantized tint steps; undefined draws the tile in its natural colors. */24+ tints?: Steps3;25+ /** Selection weight; weights in a variant list sum to 100. */26+ w: number;27+}28+29+export interface TerrainDecor {30+ glyph: UrizenName;31+ /** Fixed tint (never jittered, to bound the tint cache); undefined = natural. */32+ tint?: string;33+}34+35+export interface TerrainStyle {36+ label: string;37+ /** Background fill steps (dim/base/bright), hash-picked per cell. */38+ bg: Steps3;39+ /** Weighted glyph variants; the first entry is the dominant "base" look. */40+ variants: readonly TerrainVariant[];41+ /** Sparse accent set, hash-picked on decor cells in place of the variant. */42+ decor?: readonly TerrainDecor[];43+ /** Decor appears on roughly 1-in-decorEvery cells. */44+ decorEvery?: number;45+}46+47+/** Multiply a #rrggbb color's brightness by `f` (clamped). */48+export function shade(hex: string, f: number): string {49+ const n = Number.parseInt(hex.slice(1), 16);50+ const ch = (v: number): string =>51+ Math.max(0, Math.min(255, Math.round(v * f)))52+ .toString(16)53+ .padStart(2, '0');54+ return `#${ch(n >> 16)}${ch((n >> 8) & 0xff)}${ch(n & 0xff)}`;55+}56+57+// Deliberately subtle spreads: enough to break up flat fields, not enough to58+// read as a checkerboard.59+const bg3 = (base: string): Steps3 => [shade(base, 0.88), base, shade(base, 1.12)];60+const tint3 = (base: string): Steps3 => [shade(base, 0.84), base, shade(base, 1.16)];61+62+/** Fog fallback for unknown chars (and '?'). Solid black, nothing drawn. */63+export const FOG: TerrainStyle = {64+ label: 'unexplored',65+ bg: ['#000000', '#000000', '#000000'],66+ variants: [{ w: 100 }],67+};68+69+/**70+ * Per-TerrainChar render styles. Busy full-map biomes (grass/hills/steppe/71+ * arctic) stay tint-muted so settlements/warbands remain the brightest things72+ * on screen; decor tints are likewise kept dusty.73+ */74+export const TERRAIN: Record<string, TerrainStyle> = {75+ '?': FOG,76+ '.': {77+ label: 'plains',78+ bg: bg3('#101a0e'),79+ variants: [80+ { glyph: 'terrain.plains', tints: tint3('#3d5c31'), w: 55 }, // grass tufts81+ { glyph: 'terrain.plains.tufts', tints: tint3('#3d5c31'), w: 18 }, // sparser tufts82+ { glyph: 'terrain.plains.blades', tints: tint3('#3d5c31'), w: 15 }, // thin blades83+ { glyph: 'terrain.plains.bare', tints: tint3('#33452c'), w: 12 }, // bare speckled patch84+ ],85+ decor: [86+ { glyph: 'decor.flower', tint: '#a89a4e' }, // straw-yellow bloom87+ { glyph: 'decor.shrub', tint: '#4a6b3a' },88+ { glyph: 'decor.boulder', tint: '#54585f' },89+ ],90+ decorEvery: 20,91+ },92+ f: {93+ label: 'forest',94+ bg: bg3('#0b140a'),95+ variants: [96+ { glyph: 'terrain.forest', w: 45 }, // round deciduous pair97+ { glyph: 'terrain.forest.checker', w: 20 }, // checker-foliage pair98+ { glyph: 'tree.pine', w: 20 }, // pine clump99+ { glyph: 'terrain.forest.oak', w: 15 }, // single broad oak100+ ],101+ decor: [102+ { glyph: 'decor.mushroom' }, // red-cap mushroom (natural colors)103+ { glyph: 'decor.stump' },104+ { glyph: 'decor.deadtree' },105+ ],106+ decorEvery: 18,107+ },108+ h: {109+ label: 'hills',110+ bg: bg3('#131a0f'),111+ variants: [112+ { glyph: 'terrain.hills', tints: tint3('#5b7d43'), w: 60 }, // twin mounds113+ { glyph: 'terrain.hills.triple', tints: tint3('#5b7d43'), w: 25 }, // jagged triple114+ { glyph: 'terrain.hills.dune', tints: tint3('#54713f'), w: 15 }, // low single mound115+ ],116+ decor: [117+ { glyph: 'decor.boulder', tint: '#62666d' },118+ { glyph: 'decor.shrub', tint: '#4a6b3a' },119+ ],120+ decorEvery: 24,121+ },122+ m: {123+ label: 'mountain',124+ bg: bg3('#15151a'),125+ variants: [126+ { glyph: 'terrain.mountain', w: 62 }, // gray twin peaks (natural)127+ { glyph: 'terrain.mountain.snowcap', w: 22 }, // snow-capped peak (natural)128+ { glyph: 'terrain.hills.triple', tints: tint3('#565b64'), w: 16 }, // rocky crags129+ ],130+ decor: [{ glyph: 'decor.boulder', tint: '#8a8f99' }],131+ decorEvery: 18,132+ },133+ w: {134+ label: 'water',135+ bg: bg3('#081831'),136+ variants: [137+ { glyph: 'terrain.water', tints: tint3('#4c86b8'), w: 50 }, // mid-blue waves138+ { w: 27 }, // calm open water (background only)139+ { glyph: 'terrain.water', tints: tint3('#33587e'), w: 23 }, // dim deep swell140+ ],141+ decor: [{ glyph: 'decor.swirl' }], // teal eddy (natural colors)142+ decorEvery: 26,143+ },144+ r: {145+ label: 'river',146+ bg: bg3('#0c2547'),147+ variants: [148+ { glyph: 'terrain.river', tints: tint3('#7ec8ff'), w: 70 }, // bright ripples149+ { w: 30 }, // calm stretch150+ ],151+ decor: [{ glyph: 'decor.swirl', tint: '#7ec8ff' }],152+ decorEvery: 28,153+ },154+ s: {155+ label: 'swamp',156+ bg: bg3('#0e1710'),157+ variants: [158+ { glyph: 'terrain.swamp', w: 50 }, // green marsh mottling (natural)159+ { glyph: 'terrain.swamp.mud', w: 20 }, // brown mud mottling (natural)160+ { glyph: 'terrain.swamp.lilypad', tints: tint3('#3f6b3f'), w: 15 },161+ { w: 15 }, // still murk (background only)162+ ],163+ decor: [164+ { glyph: 'decor.reeds', tint: '#5d7a4a' },165+ { glyph: 'decor.mushroom' },166+ ],167+ decorEvery: 12,168+ },169+ t: {170+ label: 'steppe',171+ bg: bg3('#1a1409'),172+ variants: [173+ { glyph: 'terrain.steppe', tints: tint3('#8f6c30'), w: 55 }, // dry tufts174+ { glyph: 'terrain.steppe.tall', tints: tint3('#8f6c30'), w: 20 }, // tall grass175+ { glyph: 'terrain.steppe.dense', tints: tint3('#7d5e2a'), w: 10 }, // dense stand176+ { glyph: 'terrain.plains.bare', tints: tint3('#6b532a'), w: 15 }, // bare dust177+ ],178+ decor: [179+ { glyph: 'decor.deadtree' }, // gnarled dead tree (natural brown)180+ { glyph: 'decor.boulder', tint: '#7a715f' },181+ { glyph: 'decor.cactus', tint: '#6b7a3f' },182+ ],183+ decorEvery: 16,184+ },185+ a: {186+ label: 'arctic',187+ bg: bg3('#161b20'),188+ variants: [189+ { glyph: 'terrain.arctic', tints: tint3('#aebfcc'), w: 50 }, // snow drifts190+ { glyph: 'terrain.arctic.sparkle', tints: tint3('#93a5b4'), w: 15 }, // wind-blown speckle191+ { w: 35 }, // featureless snowfield (background only)192+ ],193+ decor: [194+ { glyph: 'decor.bones' }, // scattered bones (natural white)195+ { glyph: 'decor.deadtree', tint: '#8795a3' },196+ ],197+ decorEvery: 18,198+ },199+ v: {200+ label: 'volcano',201+ bg: bg3('#1d0d08'),202+ variants: [{ glyph: 'terrain.volcano', w: 100 }],203+ },204+ O: {205+ label: 'ore vein',206+ bg: bg3('#15151a'),207+ variants: [{ glyph: 'terrain.ore', tints: tint3('#aeb9c9'), w: 100 }],208+ },209+ G: {210+ label: 'gold vein',211+ bg: bg3('#15151a'),212+ variants: [{ glyph: 'terrain.gold', tints: tint3('#e8bf45'), w: 100 }],213+ },214+};215+216+/** Shore water (a 'w' cell 4-adjacent to land): lighter tone + foam ripples. */217+export const SHORE: { bg: Steps3; tints: Steps3 } = {218+ bg: bg3('#0d2748'),219+ tints: tint3('#8ec7dc'),220+};221+222+/** True when `ch` is a solid-ground TerrainChar (for shoreline detection). */223+export function isLandChar(ch: string): boolean {224+ return ch !== 'w' && ch !== 'r' && ch !== '?' && ch in TERRAIN;225+}226+227+/**228+ * splitmix32-style integer mix of cell coordinates. Pure and cheap; the same229+ * (x, y) always yields the same 32-bit hash, so the terrain layer is230+ * deterministic across redraws. Distinct bit ranges of the result drive231+ * independent choices: bits 0-1 background step, 2-8 variant roll,232+ * 9-15 decor gate, 16-23 decor index, 24-31 tint step.233+ */234+export function cellHash(x: number, y: number): number {235+ let h = (Math.imul(x, 0x9e3779b1) ^ Math.imul(y ^ 0x6d2b79f5, 0x85ebca77)) >>> 0;236+ h ^= h >>> 16;237+ h = Math.imul(h, 0x21f0aaad);238+ h ^= h >>> 15;239+ h = Math.imul(h, 0x735a2d97);240+ h ^= h >>> 15;241+ return h >>> 0;242+}243+244+/** Hash-pick one of 3 quantized steps (background or tint). */245+export function stepOf(steps: Steps3, h: number, bits: number): string {246+ return steps[(h >>> bits) % 3] ?? steps[1];247+}248+249+/** Weighted variant choice; the first (dominant) entry wins ties/overflow. */250+export function pickVariant(t: TerrainStyle, h: number): TerrainVariant {251+ let roll = (h >>> 2) % 100;252+ for (const v of t.variants) {253+ roll -= v.w;254+ if (roll < 0) return v;255+ }256+ return t.variants[0] ?? { w: 100 };257+}258+259+/** Decor for this cell, or null for the vast majority of cells. */260+export function pickDecor(t: TerrainStyle, h: number): TerrainDecor | null {261+ if (!t.decor || !t.decorEvery) return null;262+ if ((h >>> 9) % t.decorEvery !== 0) return null;263+ return t.decor[(h >>> 16) % t.decor.length] ?? null;264+}@@ -26,7 +26,34 @@ export const URIZEN: Record<string, readonly [number, number]> = {
2626 'terrain.volcano': [16, 34], // erupting red volcano2727 'terrain.ore': [4, 13], // rock face with a large nugget (tinted steel at draw)2828 'terrain.gold': [4, 13], // same nugget rock, tinted gold at draw29- 'tree.pine': [2, 34], // clump of pines (forest variety, unused fallback)29+ 'tree.pine': [2, 34], // clump of pines (forest variant)30+31+ // ---- per-cell terrain variants (weighted sets in terrain-style.ts) ----32+ 'terrain.plains.tufts': [4, 9], // two small grass tufts (sparser than base)33+ 'terrain.plains.blades': [3, 9], // three thin grass blades34+ 'terrain.plains.bare': [5, 13], // sparse dirt speckles (near-bare patch)35+ 'terrain.forest.checker': [1, 34], // pair of checker-foliage trees36+ 'terrain.forest.oak': [0, 8], // single broad round-canopy tree37+ 'terrain.hills.triple': [14, 34], // three jagged mounds38+ 'terrain.hills.dune': [19, 34], // single low dune mound39+ 'terrain.mountain.snowcap': [17, 34], // snow-capped white peak40+ 'terrain.swamp.mud': [10, 34], // brown mud mottling41+ 'terrain.swamp.lilypad': [2, 9], // lily pad42+ 'terrain.steppe.tall': [6, 34], // tall dry grass stalks43+ 'terrain.steppe.dense': [5, 34], // dense tall grass stand44+ 'terrain.arctic.sparkle': [18, 34], // wind-blown speckle field45+46+ // ---- sparse terrain decor (low-density accents, terrain-style.ts) ----47+ 'decor.flower': [15, 9], // single tulip-like flower48+ 'decor.shrub': [17, 9], // dense leafy shrub49+ 'decor.boulder': [4, 13], // round rock (shares the ore tile, tinted gray)50+ 'decor.mushroom': [2, 10], // red-cap mushroom51+ 'decor.stump': [4, 10], // tree stump52+ 'decor.deadtree': [5, 10], // gnarled dead tree53+ 'decor.bones': [3, 10], // scattered white bones54+ 'decor.reeds': [6, 9], // tall marsh reeds55+ 'decor.cactus': [0, 9], // cactus56+ 'decor.swirl': [4, 34], // teal water swirl/eddy3057 3158 // ---- settlement tiers 0-3 (camp / village / town / city) ----3259 'settlement.camp': [23, 33], // small brown tent@@ -1,16 +1,30 @@
1+/* ==========================================================================2+ * TRIBES — 1-bit terminal wargame console.3+ *4+ * Design language: near-black ground, panels drawn as 1px box frames with5+ * their titles punched into the border (roguelike box style), VT323 pixel6+ * type for all HUD text/headings/numbers/feeds, reversed-video active7+ * states, square corners, faint scanlines over the game screen. Tribe8+ * identity colors (viz/palette.ts) are the only saturated hues.9+ * ========================================================================== */10+111 :root {2- --bg: #0e1014;3- --bg-raised: #161a21;4- --bg-inset: #0a0c10;5- --border: #262c36;6- --text: #d8dee8;7- --text-dim: #8b94a3;12+ --bg: #060708;13+ --bg-raised: #0a0c0f;14+ --bg-inset: #000000;15+ --chart-bg: #08090c;16+ --border: #2b313c;17+ --border-dim: #1a1e26;18+ --border-bright: #4c5567;19+ --text: #d6dae2;20+ --text-dim: #808a9a;821 --accent: #e0a458;922 --accent-dim: #8a6534;1023 --danger: #e6533c;1124 --ok: #58c15c;12- --radius: 6px;25+ --radius: 0;1326 --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;27+ --pixel: 'VT323', 'Courier New', monospace;1428 }1529 1630 * { box-sizing: border-box; }@@ -20,100 +34,158 @@ html, body {
2034 padding: 0;2135 background: var(--bg);2236 color: var(--text);23- font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;37+ font: 13px/1.55 var(--mono);2438 }2539 2640 #app { min-height: 100vh; display: flex; flex-direction: column; }2741 42+::selection { background: var(--accent); color: #000; }43+2844 a { color: var(--accent); text-decoration: none; }2945 a:hover { text-decoration: underline; }3046 31-h1, h2, h3 { font-weight: 600; margin: 0 0 0.5rem; }32-h1 { font-size: 1.3rem; letter-spacing: 0.04em; }33-h2 { font-size: 1.05rem; }34-h3 { font-size: 0.95rem; }47+h1, h2, h3 {48+ font-family: var(--pixel);49+ font-weight: 400;50+ margin: 0 0 0.5rem;51+ letter-spacing: 0.05em;52+ text-transform: uppercase;53+}54+h1 { font-size: 1.55rem; }55+h2 { font-size: 1.25rem; }56+h3 { font-size: 1.1rem; color: var(--accent); }3557 36-/* ------- shell ------- */58+/* ------- shell: title bar + status line ------- */3759 .topbar {38- display: flex; align-items: center; gap: 1rem;39- padding: 0.6rem 1.2rem;60+ display: flex; align-items: baseline; gap: 1rem;61+ padding: 0.45rem 1.2rem 0.4rem;4062 border-bottom: 1px solid var(--border);41- background: var(--bg-raised);63+ background: var(--bg-inset);64+ position: relative;4265 }43-.topbar .brand { font-weight: 700; letter-spacing: 0.12em; color: var(--accent); font-size: 1rem; }66+.topbar::after {67+ /* second hairline under the title bar — double-rule frame */68+ content: '';69+ position: absolute; left: 0; right: 0; bottom: -4px;70+ border-bottom: 1px solid var(--border-dim);71+ pointer-events: none;72+}73+.topbar .brand {74+ font-family: var(--pixel);75+ font-size: 1.7rem;76+ line-height: 1;77+ letter-spacing: 0.22em;78+ color: var(--accent);79+ text-shadow: 0 0 12px rgba(224, 164, 88, 0.35);80+}81+.topbar .brand:hover { text-decoration: none; color: #f0be79; }82+.topbar > .muted { font-family: var(--pixel); font-size: 1.05rem; letter-spacing: 0.06em; }4483 .topbar .spacer { flex: 1; }45-.topbar .who { color: var(--text-dim); font-size: 0.85rem; }84+.topbar .who { color: var(--text-dim); font-family: var(--pixel); font-size: 1.05rem; }4685 47-main.page { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; padding: 1.2rem; }48-main.page.wide { max-width: 1500px; }86+main.page { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; padding: 1.6rem 1.2rem; }87+main.page.wide { max-width: 1560px; padding: 0.9rem 1rem 1.2rem; }4988 5089 footer.credits {5190 border-top: 1px solid var(--border);52- padding: 0.7rem 1.2rem;91+ padding: 0.45rem 1.2rem;5392 color: var(--text-dim);54- font-size: 0.78rem;93+ font-family: var(--pixel);94+ font-size: 1rem;95+ letter-spacing: 0.05em;5596 text-align: center;97+ background: var(--bg-inset);5698 }5799 58-/* ------- widgets ------- */100+/* ------- HUD box: 1px frame, title punched into the border ------- */59101 .card {60- background: var(--bg-raised);102+ position: relative;103+ background: var(--bg);61104 border: 1px solid var(--border);62- border-radius: var(--radius);63- padding: 1rem;64- margin-bottom: 1.2rem;105+ padding: 1.2rem 1rem 1rem;106+ margin-bottom: 1.6rem;65107 }108+.card > h2 {109+ position: absolute;110+ top: -0.78em;111+ left: 12px;112+ margin: 0;113+ padding: 0 0.55em;114+ background: var(--bg);115+ color: var(--accent);116+ font-size: 1.2rem;117+ line-height: 1.15;118+}119+.card > h2::before { content: '» '; color: var(--border-bright); }120+.card h3 {121+ margin-top: 1.1rem;122+ color: var(--text);123+ border-bottom: 1px solid var(--border-dim);124+ padding-bottom: 0.2rem;125+}126+.card h3::before { content: '\25B8 '; color: var(--accent-dim); }66127 128+/* ------- controls ------- */67129 button, .btn {130+ font-family: var(--pixel);131+ font-size: 1.05rem;132+ line-height: 1.25;133+ text-transform: uppercase;134+ letter-spacing: 0.06em;68135 background: var(--bg-inset);69136 color: var(--text);70137 border: 1px solid var(--border);71- border-radius: var(--radius);72- padding: 0.35rem 0.8rem;73- font: inherit;138+ border-radius: 0;139+ padding: 0.16rem 0.7rem 0.12rem;74140 cursor: pointer;75141 }76-button:hover:not(:disabled) { border-color: var(--accent-dim); color: var(--accent); }77-button:disabled { opacity: 0.45; cursor: default; }78-button.primary { background: var(--accent); color: #14100a; border-color: var(--accent); font-weight: 600; }79-button.primary:hover:not(:disabled) { color: #000; filter: brightness(1.1); }80-button.small { padding: 0.15rem 0.5rem; font-size: 0.8rem; }81-button.active { border-color: var(--accent); color: var(--accent); }142+button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }143+button:active:not(:disabled) { background: var(--accent); color: #000; }144+button:disabled { opacity: 0.4; cursor: default; }145+button.primary { background: var(--accent); color: #000; border-color: var(--accent); }146+button.primary:hover:not(:disabled) { background: #f0be79; border-color: #f0be79; color: #000; }147+button.small { padding: 0.08rem 0.5rem 0.05rem; font-size: 0.95rem; }148+button.active { background: var(--accent); color: #000; border-color: var(--accent); }82149 button.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }83150 84151 input[type="text"], input[type="number"], select, textarea {85152 background: var(--bg-inset);86153 color: var(--text);87154 border: 1px solid var(--border);88- border-radius: var(--radius);89- padding: 0.35rem 0.55rem;155+ border-radius: 0;156+ padding: 0.3rem 0.55rem;90157 font: inherit;91158 }159+select { font-family: var(--pixel); font-size: 1rem; text-transform: uppercase; letter-spacing: 0.04em; }92160 textarea { font-family: var(--mono); font-size: 12px; width: 100%; resize: vertical; }93-input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-dim); }161+input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }94162 95163 .row { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }96-.muted { color: var(--text-dim); font-size: 0.85rem; }97-.mono { font-family: var(--mono); font-size: 0.85em; }98-.error-text { color: var(--danger); font-size: 0.85rem; white-space: pre-wrap; }99-.ok-text { color: var(--ok); font-size: 0.85rem; }164+.muted { color: var(--text-dim); font-size: 0.9em; }165+.mono { font-family: var(--mono); font-size: 0.9em; }166+.error-text { color: var(--danger); font-size: 0.9em; white-space: pre-wrap; }167+.ok-text { color: var(--ok); font-size: 0.9em; }100168 101169 .star-btn { background: none; border: none; font-size: 1rem; padding: 0 0.2rem; color: var(--text-dim); }102170 .star-btn.starred { color: var(--accent); }103-.star-btn:hover { color: var(--accent); border: none; }171+.star-btn:hover { color: var(--accent); border: none; background: none; }104172 105173 table.list { width: 100%; border-collapse: collapse; }106-table.list th { text-align: left; color: var(--text-dim); font-weight: 500; font-size: 0.8rem; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--border); }107-table.list td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); }174+table.list th {175+ text-align: left; color: var(--text-dim);176+ font-family: var(--pixel); font-weight: 400; font-size: 1rem;177+ text-transform: uppercase; letter-spacing: 0.08em;178+ padding: 0.25rem 0.5rem; border-bottom: 1px solid var(--border);179+}180+table.list td { padding: 0.32rem 0.5rem; border-bottom: 1px solid var(--border-dim); }108181 table.list tr:last-child td { border-bottom: none; }109182 table.list tr.sel td { background: rgba(224, 164, 88, 0.07); }110183 table.list tr.clickable { cursor: pointer; }111-table.list tr.clickable:hover td { background: rgba(255, 255, 255, 0.03); }184+table.list tr.clickable:hover td { background: rgba(224, 164, 88, 0.06); }112185 113186 pre.snippet {114187 background: var(--bg-inset);115188 border: 1px solid var(--border);116- border-radius: var(--radius);117189 padding: 0.7rem;118190 font-family: var(--mono);119191 font-size: 12px;@@ -123,243 +195,438 @@ pre.snippet {
123195 .token-once {124196 background: var(--bg-inset);125197 border: 1px dashed var(--accent-dim);126- border-radius: var(--radius);127198 padding: 0.6rem;128199 margin: 0.6rem 0;129200 }130201 131-/* ------- viewer ------- */132-.viewer-layout { display: flex; gap: 1rem; align-items: flex-start; }133-.viewer-main { flex: 1; min-width: 0; }134-.viewer-side { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; gap: 0.7rem; }135-@media (max-width: 900px) { .viewer-layout { flex-direction: column; } .viewer-side { width: 100%; } }202+.notice {203+ padding: 2rem;204+ text-align: center;205+ color: var(--text-dim);206+ font-family: var(--pixel);207+ font-size: 1.2rem;208+ letter-spacing: 0.06em;209+}136210 137-.canvas-wrap {211+.example-row { border-top: 1px solid var(--border-dim); padding: 0.6rem 0; }212+.example-row pre.source {138213 background: var(--bg-inset);139214 border: 1px solid var(--border);140- border-radius: var(--radius);215+ padding: 0.7rem;216+ font-family: var(--mono);217+ font-size: 12px;218+ max-height: 20rem;141219 overflow: auto;142- max-height: 78vh;143220 }144-canvas.map {145- display: block;146- image-rendering: pixelated;147- width: 100%;221+222+/* ==========================================================================223+ * GAME SCREEN (/s/:id) — framed viewport + HUD, not tabs-over-cards224+ * ========================================================================== */225+226+.game-screen { position: relative; }227+/* faint scanlines + vignette over the whole game screen; stays classy */228+.game-screen::after {229+ content: '';230+ position: absolute; inset: 0;231+ pointer-events: none;232+ z-index: 40;233+ background:234+ radial-gradient(ellipse at center, transparent 62%, rgba(0, 0, 0, 0.28) 100%),235+ repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.16) 0 1px, transparent 1px 3px);148236 }149-canvas.map.zoomed { width: 2048px; max-width: none; }150237 151-.sim-controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; padding: 0.6rem 0; }152-.progress-wrap { flex: 1; min-width: 140px; display: flex; align-items: center; gap: 0.5rem; }153-.progress-bar { flex: 1; height: 6px; background: var(--bg-inset); border-radius: 3px; overflow: hidden; border: 1px solid var(--border); }238+/* ------- top status bar: sim identity - mode toggle - transport - clock --- */239+.hud-status {240+ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;241+ background: var(--bg-inset);242+ border: 1px solid var(--border);243+ padding: 0.35rem 0.8rem;244+ margin-bottom: 0.8rem;245+ font-family: var(--pixel);246+}247+.hud-ident { display: flex; align-items: baseline; gap: 0.7rem; min-width: 0; }248+.hud-ident h1 { margin: 0; font-size: 1.45rem; line-height: 1.1; color: var(--text); }249+.hud-ident h1::before { content: '» '; color: var(--accent-dim); }250+.hud-ident .sub { color: var(--text-dim); font-size: 1rem; letter-spacing: 0.05em; white-space: nowrap; }251+.hud-mock { color: var(--danger); font-size: 1rem; letter-spacing: 0.08em; }252+253+.mode-toggle { display: inline-flex; border: 1px solid var(--border); }254+button.mode-btn {255+ border: none; border-radius: 0;256+ background: var(--bg-inset);257+ color: var(--text-dim);258+ font-size: 1.1rem;259+ letter-spacing: 0.1em;260+ padding: 0.18rem 1rem 0.12rem;261+}262+button.mode-btn + button.mode-btn { border-left: 1px solid var(--border); }263+button.mode-btn.active { background: var(--accent); color: #000; }264+button.mode-btn:hover:not(.active):not(:disabled) { color: var(--accent); }265+266+.transport { display: flex; gap: 0.35rem; align-items: center; }267+.transport button { min-width: 2.2rem; text-align: center; }268+269+.hud-clock {270+ display: flex; align-items: center; gap: 0.6rem;271+ margin-left: auto;272+ min-width: 220px; flex: 1; max-width: 380px;273+}274+.tick-label {275+ font-family: var(--pixel);276+ font-size: 1.3rem;277+ color: var(--accent);278+ letter-spacing: 0.05em;279+ white-space: nowrap;280+ font-variant-numeric: tabular-nums;281+}282+.progress-bar {283+ flex: 1; height: 12px; min-width: 90px;284+ background: var(--bg-inset);285+ border: 1px solid var(--border);286+ overflow: hidden;287+ position: relative;288+}154289 .progress-bar > div { height: 100%; background: var(--accent); width: 0; }290+.progress-bar::after {291+ /* segment the fill into chunky cells */292+ content: '';293+ position: absolute; inset: 0;294+ background: repeating-linear-gradient(90deg, transparent 0 7px, var(--bg-inset) 7px 9px);295+ pointer-events: none;296+}155297 156-.tribe-panel {157- background: var(--bg-raised);298+/* ------- layout: viewport + world log left, faction HUD right ------- */299+.game-layout { display: flex; gap: 0.9rem; align-items: flex-start; }300+.game-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.8rem; }301+.game-side { width: 316px; flex-shrink: 0; display: flex; flex-direction: column; gap: 0.9rem; }302+@media (max-width: 980px) { .game-layout { flex-direction: column; } .game-side { width: 100%; } }303+304+/* the framed viewport (double frame: border + offset outline) */305+.viewport {306+ position: relative;158307 border: 1px solid var(--border);159- border-left-width: 4px;160- border-radius: var(--radius);161- padding: 0.5rem 0.7rem;162- font-size: 0.85rem;308+ outline: 1px solid var(--border-dim);309+ outline-offset: 3px;310+ background: var(--bg-inset);311+ padding: 0.7rem;312+ margin: 4px; /* room for the outline */313+}314+.viewport-label {315+ position: absolute;316+ top: -0.72em; left: 14px;317+ padding: 0 0.5em;318+ background: var(--bg);319+ font-family: var(--pixel);320+ font-size: 1.15rem;321+ letter-spacing: 0.14em;322+ text-transform: uppercase;323+ color: var(--accent);324+ z-index: 2;325+}326+.viewport-label::before { content: '\2508 '; color: var(--border-bright); }327+.viewport-label::after { content: ' \2508'; color: var(--border-bright); }328+329+.world-toolbar { margin-bottom: 0.55rem; }330+331+/* invitation overlay for the instant-chronicle path */332+.replay-invite {333+ display: flex; flex-direction: column; align-items: center; justify-content: center;334+ gap: 0.9rem;335+ min-height: 340px;336+ text-align: center;337+}338+.replay-invite .glyphs { display: flex; gap: 0.7rem; }339+.replay-invite canvas { image-rendering: pixelated; }340+button.replay-btn {341+ font-size: 1.6rem;342+ padding: 0.35rem 1.6rem 0.25rem;343+ border-width: 1px;344+ background: var(--bg-inset);345+ color: var(--accent);346+ border-color: var(--accent);347+ box-shadow: 0 0 0 1px var(--bg-inset), 0 0 0 2px var(--accent-dim);163348 }164-.tribe-panel.dead { opacity: 0.5; }165-.tribe-panel .head { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }166-.tribe-panel canvas { image-rendering: pixelated; }167-.tribe-panel .stats { display: flex; gap: 0.9rem; margin-top: 0.25rem; color: var(--text-dim); flex-wrap: wrap; }168-.tribe-panel .stat { display: inline-flex; align-items: center; gap: 0.25rem; }169-.tribe-panel .stat img { image-rendering: pixelated; width: 16px; height: 16px; }170-.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }171-.dot.over { background: var(--danger); }349+button.replay-btn:hover:not(:disabled) { background: var(--accent); color: #000; }350+.replay-invite .hint { color: var(--text-dim); font-family: var(--pixel); font-size: 1.05rem; max-width: 40ch; }172351 173-/* ------- event feed ------- */352+/* simulating-world strip (auto max-speed run when no stored metrics) */353+.simulating-note {354+ border: 1px solid var(--accent-dim);355+ background: var(--bg-inset);356+ padding: 0.45rem 0.9rem;357+ font-family: var(--pixel);358+ font-size: 1.15rem;359+ letter-spacing: 0.08em;360+ color: var(--accent);361+ text-transform: uppercase;362+}363+.simulating-note .blink { animation: cursor-blink 1s steps(1) infinite; }364+365+.hash-line {366+ color: var(--text-dim);367+ font-family: var(--mono);368+ font-size: 0.72rem;369+ margin: 0;370+ overflow-wrap: anywhere;371+}372+373+/* ------- terminal feeds ------- */174374 .feed-panel {175- background: var(--bg-raised);375+ position: relative;376+ background: var(--bg-inset);176377 border: 1px solid var(--border);177- border-radius: var(--radius);178378 display: flex;179379 flex-direction: column;180380 min-height: 0;181- overflow: hidden;381+ overflow: visible;182382 }183383 .feed-head {184- padding: 0.35rem 0.7rem;185- font-size: 0.72rem;186- font-weight: 600;187- letter-spacing: 0.1em;384+ position: absolute;385+ top: -0.72em; left: 10px;386+ padding: 0 0.5em;387+ background: var(--bg);388+ font-family: var(--pixel);389+ font-size: 1.05rem;390+ letter-spacing: 0.12em;188391 text-transform: uppercase;189392 color: var(--text-dim);190- border-bottom: 1px solid var(--border);191- background: var(--bg-inset);393+ z-index: 2;192394 }395+.feed-head::before { content: '\25AA '; color: var(--accent-dim); }193396 .feed-body {194- font-family: var(--mono);195- font-size: 11.5px;196- line-height: 1.5;197- padding: 0.4rem 0.6rem;198- height: 260px;397+ font-family: var(--pixel);398+ font-size: 1.02rem;399+ line-height: 1.35;400+ letter-spacing: 0.02em;401+ padding: 0.9rem 0.6rem 0.35rem;402+ height: 190px;199403 overflow-y: auto;200404 overflow-x: hidden;201405 }406+.feed-body::after {407+ content: '\2588';408+ color: var(--accent);409+ animation: cursor-blink 1.1s steps(1) infinite;410+}411+@keyframes cursor-blink { 50% { opacity: 0; } }202412 .feed-line { overflow-wrap: anywhere; color: var(--text); }203413 .feed-tick { color: var(--text-dim); }204-.feed-name { font-weight: 600; }414+.feed-name { font-weight: 400; }205415 .feed-swatch {206416 display: inline-block;207417 width: 7px; height: 7px;208- border-radius: 2px;209418 margin-right: 0.35em;210419 vertical-align: baseline;211420 }212-.feed-say .feed-say-text { color: var(--accent); font-style: italic; }213-.feed-fate { color: var(--danger); font-weight: 600; }214-.feed-log { color: var(--text-dim); font-style: italic; }215-.feed-panel.in-card { margin-top: 0.45rem; border-radius: 4px; }216-.feed-body.compact { height: 110px; font-size: 10.5px; padding: 0.25rem 0.45rem; }421+.feed-say .feed-say-text { color: var(--accent); }422+.feed-fate { color: var(--danger); }423+.feed-log { color: var(--text-dim); }424+.feed-panel.in-card { margin-top: 0.85rem; }425+.feed-body.compact { height: 108px; font-size: 0.95rem; padding: 0.45rem 0.5rem 0.25rem; }426+427+/* the world log strip under the viewport */428+.world-log .feed-body { height: 150px; }217429 218-/* ------- map tooltip ------- */430+/* ------- faction HUD panels ------- */431+.tribe-panel {432+ position: relative;433+ background: var(--bg-inset);434+ border: 1px solid var(--border);435+ border-left-width: 3px; /* tribe color set inline */436+ padding: 0.55rem 0.65rem 0.6rem;437+ font-size: 0.85rem;438+}439+.tribe-panel.dead { opacity: 0.45; filter: grayscale(0.6); }440+.tribe-panel .head {441+ display: flex; align-items: center; gap: 0.5rem;442+ font-family: var(--pixel);443+ font-size: 1.2rem;444+ letter-spacing: 0.05em;445+ text-transform: uppercase;446+}447+.tribe-panel canvas.portrait { image-rendering: pixelated; width: 30px; height: 30px; flex-shrink: 0; }448+.tribe-panel .status-word { font-size: 0.95rem; letter-spacing: 0.1em; }449+.tribe-panel .stats {450+ display: flex; gap: 0.75rem; margin-top: 0.3rem;451+ color: var(--text-dim); flex-wrap: wrap;452+ font-family: var(--pixel); font-size: 1rem;453+}454+.tribe-panel .stat { display: inline-flex; align-items: baseline; gap: 0.3rem; text-transform: uppercase; letter-spacing: 0.04em; }455+.tribe-panel .stat .mono { font-family: var(--pixel); font-size: 1.05rem; color: var(--text); }456+.dot { display: inline-block; width: 8px; height: 8px; }457+.dot.over { background: var(--danger); box-shadow: 0 0 6px var(--danger); }458+459+/* ------- map tooltip (created by render/map-view.ts) ------- */219460 .map-tooltip {220461 position: fixed;221462 z-index: 50;222463 pointer-events: none;223- background: rgba(10, 12, 16, 0.94);224- border: 1px solid var(--border);225- border-left: 3px solid var(--border);226- border-radius: 4px;464+ background: rgba(0, 0, 0, 0.95);465+ border: 1px solid var(--border-bright);227466 padding: 0.3rem 0.55rem;228- font-family: var(--mono);229- font-size: 11.5px;230- line-height: 1.45;467+ font-family: var(--pixel);468+ font-size: 1rem;469+ line-height: 1.3;231470 color: var(--text);232471 max-width: 240px;233472 white-space: nowrap;234- box-shadow: 0 3px 10px rgba(0, 0, 0, 0.55);473+ box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.6);235474 }236-.map-tooltip .tt-title { font-weight: 700; }475+.map-tooltip .tt-title { color: var(--accent); }237476 .map-tooltip .tt-dim { color: var(--text-dim); }238477 478+/* ------- endgame banner ------- */239479 .result-banner {240- border: 1px solid var(--accent-dim);241- background: rgba(224, 164, 88, 0.08);242- border-radius: var(--radius);243- padding: 0.8rem 1rem;244- margin-bottom: 0.8rem;480+ position: relative;481+ border: 1px solid var(--accent);482+ outline: 1px solid var(--accent-dim);483+ outline-offset: 3px;484+ background: var(--bg-inset);485+ padding: 1rem 1.2rem 0.9rem;486+ margin: 4px 4px 1rem;245487 }246-.result-banner h2 { color: var(--accent); }247-.result-banner table { margin-top: 0.4rem; }248-249-.notice { padding: 2rem; text-align: center; color: var(--text-dim); }250-251-/* ------- viewer tabs ------- */252-.viewer-toprow { justify-content: space-between; margin-bottom: 0.7rem; }253-.tab-bar { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }254-button.tab {255- border: none; border-radius: 0; background: var(--bg-inset);256- padding: 0.35rem 1.1rem; font-weight: 600; letter-spacing: 0.04em;488+.result-banner h2 {489+ color: var(--accent);490+ text-align: center;491+ font-size: 2rem;492+ margin-bottom: 0.2rem;493+ text-shadow: 0 0 16px rgba(224, 164, 88, 0.4);494+}495+.result-banner .result-sub {496+ text-align: center;497+ font-family: var(--pixel);498+ font-size: 1.1rem;257499 color: var(--text-dim);500+ letter-spacing: 0.08em;501+ text-transform: uppercase;502+ margin: 0 0 0.7rem;258503 }259-button.tab + button.tab { border-left: 1px solid var(--border); }260-button.tab.active { background: var(--bg-raised); color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }261-.tab-pane { min-width: 0; }262-263-/* watch-tab map host styles live at the end of this file (owned by the264- render/map-view.ts agent): .map-host / .map-host.zoomed */265-266-/* ------- dashboard ------- */267-.dash { display: flex; flex-direction: column; gap: 1rem; }504+.result-banner table { margin-top: 0.4rem; }505+.result-banner td.mono { font-family: var(--pixel); font-size: 1.05rem; }506+.result-banner.failed { border-color: var(--danger); outline-color: var(--danger); }507+.result-banner.failed h2 { color: var(--danger); text-shadow: none; }508+.result-rank { color: var(--accent); font-family: var(--pixel); }509+510+/* ==========================================================================511+ * CHRONICLE (stats) — in-game telemetry styling512+ * ========================================================================== */513+.dash { display: flex; flex-direction: column; gap: 1.2rem; }268514 .dash-empty { padding: 0.8rem; }269515 270516 .kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; }271517 .kpi-tile {272- background: var(--bg-raised);518+ background: var(--chart-bg);273519 border: 1px solid var(--border);274- border-radius: var(--radius);275- padding: 0.55rem 0.8rem;520+ padding: 0.45rem 0.8rem 0.5rem;521+}522+.kpi-label {523+ font-family: var(--pixel); font-size: 0.95rem;524+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim);276525 }277-.kpi-label { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); }278-.kpi-value { font-size: 1.45rem; font-weight: 600; line-height: 1.3; }279-.kpi-sub { font-size: 0.75rem; color: var(--text-dim); }526+.kpi-value { font-family: var(--pixel); font-size: 1.9rem; line-height: 1.1; color: var(--text); }527+.kpi-sub { font-family: var(--pixel); font-size: 0.95rem; color: var(--text-dim); letter-spacing: 0.04em; }280528 281529 .dash-tribe-legend { padding: 0 0.2rem; }282530 283531 .lens { display: flex; flex-direction: column; gap: 0.6rem; }284532 .lens-title {285- font-size: 0.75rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;286- color: var(--text-dim); margin: 0.3rem 0 0;287- border-bottom: 1px solid var(--border); padding-bottom: 0.3rem;533+ font-family: var(--pixel);534+ font-size: 1.2rem;535+ letter-spacing: 0.18em;536+ text-transform: uppercase;537+ color: var(--accent);538+ margin: 0.3rem 0 0;539+ border-bottom: 1px solid var(--border);540+ padding-bottom: 0.15rem;288541 }542+.lens-title::before { content: '\2261 '; color: var(--border-bright); }289543 290544 .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }291545 @media (max-width: 1100px) { .chart-grid { grid-template-columns: 1fr; } }292546 293547 .chart-card {294- background: var(--bg-raised);548+ background: var(--chart-bg);295549 border: 1px solid var(--border);296- border-radius: var(--radius);297- padding: 0.6rem 0.75rem 0.5rem;550+ padding: 0.5rem 0.75rem 0.5rem;298551 min-width: 0;299552 }300-.chart-title { font-size: 0.8rem; font-weight: 600; color: var(--text); margin-bottom: 0.35rem; }553+.chart-title {554+ font-family: var(--pixel);555+ font-size: 1.1rem;556+ letter-spacing: 0.06em;557+ text-transform: uppercase;558+ color: var(--text);559+ margin-bottom: 0.3rem;560+}561+.chart-title::before { content: '\25B8 '; color: var(--accent-dim); }301562 .chart-plot { min-width: 0; }302563 canvas.chart { display: block; width: 100%; }303564 304-.chart-legend { display: flex; flex-wrap: wrap; gap: 0.25rem 0.8rem; padding: 0.15rem 0 0.3rem; font-size: 0.72rem; color: var(--text-dim); }565+.chart-legend {566+ display: flex; flex-wrap: wrap; gap: 0.15rem 0.8rem;567+ padding: 0.1rem 0 0.3rem;568+ font-family: var(--pixel);569+ font-size: 0.98rem;570+ letter-spacing: 0.04em;571+ color: var(--text-dim);572+}305573 .legend-item { display: inline-flex; align-items: center; gap: 0.35em; }306-.legend-key { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }307-.legend-key.line { height: 3px; width: 12px; border-radius: 1.5px; }574+.legend-key { display: inline-block; width: 9px; height: 9px; flex-shrink: 0; }575+.legend-key.line { height: 3px; width: 12px; }308576 309577 /* small-multiples grids (per-tribe cards) */310578 .sm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.6rem; }311579 .sm-grid.econ { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }312580 .sm-card {313581 background: var(--bg-inset);314- border: 1px solid var(--border);315- border-left-width: 3px;316- border-radius: 4px;582+ border: 1px solid var(--border-dim);583+ border-left: 3px solid var(--border);317584 padding: 0.45rem 0.6rem;318585 min-width: 0;319586 }320-.sm-head { display: flex; align-items: center; gap: 0.4em; font-size: 0.78rem; font-weight: 600; margin-bottom: 0.3rem; }587+.sm-head {588+ display: flex; align-items: center; gap: 0.4em;589+ font-family: var(--pixel);590+ font-size: 1.05rem;591+ letter-spacing: 0.05em;592+ text-transform: uppercase;593+ margin-bottom: 0.3rem;594+}321595 322596 .spark-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.12rem 0; }323-.spark-label { width: 62px; flex-shrink: 0; font-size: 0.7rem; color: var(--text-dim); }597+.spark-label {598+ width: 64px; flex-shrink: 0;599+ font-family: var(--pixel); font-size: 0.95rem;600+ letter-spacing: 0.04em; color: var(--text-dim); text-transform: uppercase;601+}324602 .spark-row .spark { flex: 1; min-width: 0; }325-.spark-value { width: 48px; flex-shrink: 0; text-align: right; font-size: 0.72rem; color: var(--text); }603+.spark-value { width: 48px; flex-shrink: 0; text-align: right; font-family: var(--pixel); font-size: 1rem; color: var(--text); }326604 327605 .metrics-table td, .metrics-table th { font-variant-numeric: tabular-nums; }328-.metrics-table tr.dead-row td { opacity: 0.5; }606+.metrics-table td.mono { font-family: var(--pixel); font-size: 1.02rem; }607+.metrics-table tr.dead-row td { opacity: 0.45; }329608 330609 /* ------- chart tooltip ------- */331610 .chart-tip {332611 position: fixed;333612 z-index: 60;334613 pointer-events: none;335- background: rgba(10, 12, 16, 0.95);336- border: 1px solid var(--border);337- border-radius: 4px;614+ background: rgba(0, 0, 0, 0.95);615+ border: 1px solid var(--border-bright);338616 padding: 0.3rem 0.55rem;339- font-size: 11.5px;340- line-height: 1.5;617+ font-family: var(--pixel);618+ font-size: 1rem;619+ line-height: 1.35;341620 color: var(--text);342621 max-width: 260px;343- box-shadow: 0 3px 10px rgba(0, 0, 0, 0.55);622+ box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.6);344623 }345-.chart-tip .ct-title { font-family: var(--mono); color: var(--text-dim); margin-bottom: 0.1rem; }624+.chart-tip .ct-title { color: var(--accent); margin-bottom: 0.1rem; }346625 .chart-tip .ct-row { display: flex; align-items: center; gap: 0.45em; white-space: nowrap; }347-.chart-tip .ct-key { display: inline-block; width: 10px; height: 3px; border-radius: 1.5px; flex-shrink: 0; }348-.chart-tip .ct-val { font-family: var(--mono); font-weight: 700; min-width: 3.5em; }626+.chart-tip .ct-key { display: inline-block; width: 10px; height: 3px; flex-shrink: 0; }627+.chart-tip .ct-val { min-width: 3.5em; }349628 .chart-tip .ct-name { color: var(--text-dim); }350629 351-.example-row { border-top: 1px solid var(--border); padding: 0.6rem 0; }352-.example-row pre.source {353- background: var(--bg-inset);354- border: 1px solid var(--border);355- border-radius: var(--radius);356- padding: 0.7rem;357- font-family: var(--mono);358- font-size: 12px;359- max-height: 20rem;360- overflow: auto;361-}362-363630 /* ------------------------------------------------------------------------364631 * map-view (Urizen renderer) — appended by web/src/render/map-view.ts owner.365632 * The canvas renders 1:1 at 12px/tile and is scaled purely by CSS here.@@ -14,7 +14,11 @@
1414 * pixel-bucket plots its average (lines/areas) so zoomed-out charts stay1515 * faithful without aliasing.1616 */17-import { CHROME } from './palette';17+import { CHROME, PIXEL_FONT } from './palette';18+19+/** Canvas text styles — pixel/terminal font, matching the HUD chrome. */20+const AXIS_FONT = `13px ${PIXEL_FONT}`;21+const LABEL_FONT = `13px ${PIXEL_FONT}`;1822 1923 export interface Series {2024 name: string;@@ -235,7 +239,7 @@ function drawAxes(
235239 const tickToX = (t: number) => x0 + (t1 === t0 ? 0 : ((t - t0) / (t1 - t0)) * pw);236240 const valToY = (v: number) => y0 - (v / yMax) * ph;237241 238- ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';242+ ctx.font = AXIS_FONT;239243 ctx.textBaseline = 'middle';240244 241245 // horizontal gridlines + y labels (solid hairlines, one step off surface).@@ -314,8 +318,8 @@ export function lineChart(opts: LineChartOpts = {}): LineChart {
314318 315319 const maxPts = Math.max(2, Math.floor(fr.pw));316320 ctx.lineWidth = 2;317- ctx.lineJoin = 'round';318- ctx.lineCap = 'round';321+ ctx.lineJoin = 'miter'; // pixel-weight corners, not rounded strokes322+ ctx.lineCap = 'butt';319323 let leadIdx = 0;320324 for (let i = 0; i < series.length; i++) {321325 const cur = series[i]!.data[series[i]!.data.length - 1] ?? 0;@@ -338,24 +342,20 @@ export function lineChart(opts: LineChartOpts = {}): LineChart {
338342 } else ctx.lineTo(x, y);339343 }340344 ctx.stroke();341- // end dot with 2px surface ring345+ // square end marker with a 2px surface ring (1-bit pixel accent)342346 const ex = fr.x0 + fr.pw;343347 const ey = fr.valToY(ys[n - 1]!);344- ctx.beginPath();345- ctx.arc(ex, ey, 3.5, 0, Math.PI * 2);348+ ctx.fillStyle = CHROME.surface;349+ ctx.fillRect(ex - 5, ey - 5, 10, 10);346350 ctx.fillStyle = s.color;347- ctx.fill();348- ctx.lineWidth = 2;349- ctx.strokeStyle = CHROME.surface;350- ctx.stroke();351- ctx.lineWidth = 2;351+ ctx.fillRect(ex - 3, ey - 3, 6, 6);352352 }353353 // selective direct label: leader endpoint only, ink token (never series color)354354 if (opts.labelEndMax && series[leadIdx]) {355355 const s = series[leadIdx]!;356356 const last = s.data[s.data.length - 1] ?? 0;357357 ctx.fillStyle = CHROME.ink;358- ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';358+ ctx.font = LABEL_FONT;359359 ctx.textAlign = 'left';360360 ctx.textBaseline = 'middle';361361 ctx.fillText(s.name.slice(0, 9), fr.x0 + fr.pw + 7, fr.valToY(last));@@ -579,7 +579,7 @@ export function stackedBarsH(opts: { valueFormat?: (v: number) => string } = {})
579579 const scaleX = (v: number) => (v / xMax) * pw;580580 581581 // vertical gridlines582- ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';582+ ctx.font = AXIS_FONT;583583 const step = niceStep(xMax, 4);584584 ctx.strokeStyle = CHROME.grid;585585 ctx.fillStyle = CHROME.muted;@@ -601,13 +601,12 @@ export function stackedBarsH(opts: { valueFormat?: (v: number) => string } = {})
601601 const y = ri * ROW_H + (ROW_H - BAR_H) / 2;602602 // row label — muted ink, right-aligned against the bar start603603 ctx.fillStyle = CHROME.muted;604- ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';604+ ctx.font = LABEL_FONT;605605 ctx.textAlign = 'right';606606 ctx.textBaseline = 'middle';607607 ctx.fillText(r.label.slice(0, 11), x0 - 8, y + BAR_H / 2);608- // segments with 2px surface gaps; 4px rounded data-end on the last one608+ // segments with 2px surface gaps; square ends throughout609609 let acc = 0;610- const lastVisible = r.segments.reduce((li, s, i) => (s.value > 0 ? i : li), -1);611610 for (let si = 0; si < r.segments.length; si++) {612611 const seg = r.segments[si]!;613612 if (seg.value <= 0) continue;@@ -617,21 +616,14 @@ export function stackedBarsH(opts: { valueFormat?: (v: number) => string } = {})
617616 const isHover = hover && hover.row === ri && hover.seg === si;618617 ctx.fillStyle = seg.color;619618 ctx.globalAlpha = isHover ? 1 : 0.92;620- ctx.beginPath();621- if (si === lastVisible) {622- // square at baseline (left), 4px rounded data-end (right)623- const rr = Math.min(4, wSeg / 2);624- ctx.roundRect(sx, y, wSeg, BAR_H, [0, rr, rr, 0]);625- } else {626- ctx.rect(sx, y, wSeg, BAR_H);627- }628- ctx.fill();619+ // square ends everywhere — 1-bit HUD bars have no rounded corners620+ ctx.fillRect(sx, y, wSeg, BAR_H);629621 ctx.globalAlpha = 1;630622 acc += seg.value;631623 }632624 // total at the tip — ink token633625 ctx.fillStyle = CHROME.ink;634- ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';626+ ctx.font = AXIS_FONT;635627 ctx.textAlign = 'left';636628 ctx.fillText(fmt(acc), x0 + scaleX(acc) + 6, y + BAR_H / 2);637629 }@@ -717,8 +709,8 @@ export function sparkline(colors: string[], height = 26): Spark {
717709 let max = 1;718710 for (const vs of data) for (const v of vs) if (v > max) max = v;719711 ctx.lineWidth = 1.5;720- ctx.lineJoin = 'round';721- ctx.lineCap = 'round';712+ ctx.lineJoin = 'miter';713+ ctx.lineCap = 'butt';722714 for (let si = 0; si < data.length; si++) {723715 const ys = resample(data[si]!, Math.max(2, Math.floor(w)));724716 const n = ys.length;@@ -730,10 +722,8 @@ export function sparkline(colors: string[], height = 26): Spark {
730722 }731723 ctx.stroke();732724 const ey = 2 + (1 - ys[n - 1]! / max) * (h - 5);733- ctx.beginPath();734- ctx.arc(w - 3, ey, 2.5, 0, Math.PI * 2);735725 ctx.fillStyle = colors[si] ?? CHROME.muted;736- ctx.fill();726+ ctx.fillRect(w - 5, ey - 2, 4, 4);737727 }738728 });739729 sc.el.classList.add('spark');@@ -55,12 +55,15 @@ export const TIER_RAMP = ['#9ec5f4', '#5598e7', '#256abf', '#184f95'] as const;
5555 export const PROD_COLOR = '#199e70';5656 export const CONS_COLOR = '#e66767';5757 58-/** Chart chrome — matches the style.css token set (committed dark site). */58+/** Chart chrome — matches the style.css token set (1-bit terminal console). */5959 export const CHROME = {60- surface: '#161a21', // --bg-raised (card background)61- grid: '#1e232c', // one step off the surface, hairline62- axis: '#262c36', // --border63- ink: '#d8dee8', // --text64- muted: '#8b94a3', // --text-dim60+ surface: '#08090c', // --chart-bg (telemetry card background)61+ grid: '#151920', // one step off the surface, hairline62+ axis: '#2b313c', // --border63+ ink: '#d6dae2', // --text64+ muted: '#808a9a', // --text-dim6565 accent: '#e0a458', // --accent6666 } as const;67+68+/** Pixel font stack for canvas-drawn chart text (axis labels, bar totals). */69+export const PIXEL_FONT = '"VT323", "Courier New", monospace';@@ -29,7 +29,11 @@ export default {
2929 3030 if (pathname === '/mcp') {3131 if (request.method === 'OPTIONS') return corsPreflight();32- const service = createService(env, { validate, origin: new URL(request.url).origin });32+ const service = createService(env, {33+ validate,34+ origin: new URL(request.url).origin,35+ background: (work) => ctx.waitUntil(work),36+ });3337 const mcpFetch = createMcpFetchHandler({3438 service,3539 authenticate: (req) => authenticateBearer(req, env),@@ -41,7 +45,7 @@ export default {
4145 return handleLlmsTxt(docsText);4246 }4347 if (pathname.startsWith('/api/')) {44- return handleApi(request, env, ctx, { validate });48+ return handleApi(request, env, ctx, { validate, background: (work) => ctx.waitUntil(work) });4549 }4650 return env.ASSETS.fetch(request);4751 },@@ -16,6 +16,13 @@ import { CreateSimInput, StarInput, SubmitLogicInput } from '@tribes/protocol';
1616 import type { TokenRecord, UserInfo } from '@tribes/protocol';1717 import type { Env } from './env';1818 import { EXAMPLES, exampleHashes } from './examples';19+import {20+ METRICS_MAX_GZ_BYTES,21+ getStoredMetricsGz,22+ gunzipJson,23+ looksLikeStoredMetrics,24+ storeMetrics,25+} from './simrun';1926 import {2027 authenticate,2128 clearSessionCookieHeader,@@ -40,6 +47,7 @@ import { isoOf, nanoid, nowSec } from './util';
4047 4148 export interface ApiDeps {4249 validate: ServiceDeps['validate'];50+ background?: ServiceDeps['background'];4351 }4452 4553 const CORS_HEADERS: Record<string, string> = {@@ -177,17 +185,59 @@ export async function handleApi(
177185 return json(EXAMPLES.map((e) => ({ ...e, hash: hashes[e.name] })));178186 }179187 188+ // -- public: precomputed sim metrics (instant stats for share links) --189+ const metricsMatch = /^\/api\/sims\/([^/]+)\/metrics$/.exec(path);190+ if (metricsMatch && metricsMatch[1]) {191+ const simId = metricsMatch[1];192+ if (method === 'GET') {193+ const gz = await getStoredMetricsGz(env, simId);194+ if (gz === null) return err('not_found', 'metrics not computed yet', 404);195+ // Stored pre-gzipped; the browser transparently decompresses.196+ // encodeBody 'manual' tells workerd the body is ALREADY encoded per197+ // the content-encoding header (default 'automatic' would mangle it).198+ return new Response(gz, {199+ encodeBody: 'manual',200+ headers: {201+ 'content-type': 'application/json',202+ 'content-encoding': 'gzip',203+ 'cache-control': 'public, max-age=31536000, immutable',204+ ...CORS_HEADERS,205+ },206+ });207+ }208+ if (method === 'POST') {209+ // Client fallback cache: first-write-only, shape-checked, size-capped.210+ // Deterministic replays make any honest client's result identical.211+ const body = await request.arrayBuffer();212+ if (body.byteLength > METRICS_MAX_GZ_BYTES) {213+ return err('bad_input', 'metrics payload too large', 413);214+ }215+ let parsed: unknown;216+ try {217+ parsed = await gunzipJson(body);218+ } catch {219+ return err('bad_input', 'metrics payload must be gzipped JSON', 400);220+ }221+ if (!looksLikeStoredMetrics(parsed)) return err('bad_input', 'malformed metrics payload', 400);222+ const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });223+ await service.getSimBundle(simId); // 404s for missing/expired sims224+ const stored = await storeMetrics(env, simId, parsed, 'client');225+ return json({ stored });226+ }227+ return err('bad_input', 'method not allowed', 405);228+ }229+180230 // -- public: sim bundle for the share-link viewer ---------------------181231 const bundleMatch = /^\/api\/sims\/([^/]+)\/bundle$/.exec(path);182232 if (bundleMatch && bundleMatch[1] && method === 'GET') {183- const service = createService(env, { validate: deps.validate, origin: url.origin });233+ const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });184234 return json(await service.getSimBundle(bundleMatch[1]));185235 }186236 187237 // -- everything below requires auth -----------------------------------188238 const user = await authenticate(request, env);189239 if (!user) return err('unauthorized', 'authentication required', 401);190- const service = createService(env, { validate: deps.validate, origin: url.origin });240+ const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });191241 192242 if (path === '/api/me' && method === 'GET') {193243 const row = await getUserById(env.DB, user.userId);@@ -19,7 +19,7 @@ const SMOKE_TICKS = 50;
1919 const SMOKE_SEED = '424242';2020 2121 let hostPromise: Promise<ScriptHost> | null = null;22-function getHost(): Promise<ScriptHost> {22+export function getHost(): Promise<ScriptHost> {2323 hostPromise ??= createScriptHost(WorkerdReleaseSyncVariant as unknown as QuickJSSyncVariant);2424 return hostPromise;2525 }@@ -48,6 +48,7 @@ import type { Env } from './env';
4848 import * as db from './db';4949 import { isoOf, nanoid, nowSec, randomDecimalSeed, sha256Hex } from './util';5050 import { friendlyId } from './friendly-id';51+import { computeSimMetrics } from './simrun';5152 5253 const TTL_SECONDS = LIMITS.TTL_DAYS * 24 * 3600;5354 @@ -66,6 +67,11 @@ export interface ServiceDeps {
6667 validate(source: string): Promise<ValidationReport>;6768 /** Absolute request origin (e.g. https://tribes.example.com) for share URLs. */6869 origin: string;70+ /**71+ * Schedule background work past the response (ctx.waitUntil). Used to72+ * precompute sim metrics at creation so share links render instantly.73+ */74+ background?: (work: Promise<unknown>) => void;6975 }7076 7177 // ---------------------------------------------------------------------------@@ -341,6 +347,10 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
341347 }),342348 ),343349 ]);350+ // Precompute the full metrics series in the background so the share351+ // link renders finished stats instantly (client fallback covers CPU352+ // limits — see simrun.ts).353+ deps.background?.(computeSimMetrics(env, id));344354 return {345355 id,346356 seed,@@ -0,0 +1,134 @@
1+/**2+ * Server-side sim precompute: run the full deterministic match once at3+ * create_sim time (background, ctx.waitUntil) and store the complete metrics4+ * series + result, gzipped, so share links render finished stats instantly.5+ *6+ * If this dies on a CPU limit (free-plan Workers), nothing breaks: the viewer7+ * falls back to computing in-browser and POSTing the cache (first-write-only).8+ */9+import { LIMITS } from '@tribes/protocol';10+import type { MetricsFrame, Order, SimResult } from '@tribes/protocol';11+import { createWorld } from '@tribes/engine';12+import type { Env } from './env';13+import * as db from './db';14+import { nowSec } from './util';15+import { getHost } from './runtime-validation';16+17+export interface StoredMetrics {18+ frames: MetricsFrame[];19+ result: SimResult;20+ stateHash: string;21+}22+23+/** Upper bound on an accepted client-posted cache (gzipped bytes). */24+export const METRICS_MAX_GZ_BYTES = 1024 * 1024;25+26+export async function gzipJson(value: unknown): Promise<ArrayBuffer> {27+ const stream = new Blob([JSON.stringify(value)]).stream().pipeThrough(new CompressionStream('gzip'));28+ return new Response(stream).arrayBuffer();29+}30+31+export async function gunzipJson(data: ArrayBuffer): Promise<unknown> {32+ const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('gzip'));33+ return JSON.parse(await new Response(stream).text());34+}35+36+/** Light shape check for client-posted caches (server-computed ones are trusted). */37+export function looksLikeStoredMetrics(v: unknown): v is StoredMetrics {38+ if (typeof v !== 'object' || v === null) return false;39+ const m = v as Record<string, unknown>;40+ return (41+ Array.isArray(m['frames']) &&42+ m['frames'].length <= LIMITS.MAX_TICKS / LIMITS.SLOW_TICK_INTERVAL + 1 &&43+ typeof m['stateHash'] === 'string' &&44+ typeof m['result'] === 'object' &&45+ m['result'] !== null &&46+ Array.isArray((m['result'] as Record<string, unknown>)['tribes'])47+ );48+}49+50+export async function storeMetrics(51+ env: Env,52+ simId: string,53+ stored: StoredMetrics,54+ computedBy: 'server' | 'client',55+): Promise<boolean> {56+ const data = await gzipJson(stored);57+ const res = await env.DB58+ .prepare(59+ `INSERT OR IGNORE INTO sim_metrics (sim_id, data, state_hash, computed_by, created_at)60+ VALUES (?1, ?2, ?3, ?4, ?5)`,61+ )62+ .bind(simId, data, stored.stateHash, computedBy, nowSec())63+ .run();64+ return (res.meta.changes ?? 0) > 0;65+}66+67+export async function getStoredMetricsGz(env: Env, simId: string): Promise<Uint8Array | null> {68+ const row = await env.DB69+ .prepare(`SELECT data FROM sim_metrics WHERE sim_id = ?1`)70+ .bind(simId)71+ .first<{ data: unknown }>();72+ if (!row || row.data == null) return null;73+ // D1 has represented BLOBs as ArrayBuffer, typed array, or number[]74+ // depending on runtime/version — normalize all of them.75+ const d = row.data;76+ if (d instanceof Uint8Array) return d;77+ if (d instanceof ArrayBuffer) return new Uint8Array(d);78+ if (Array.isArray(d)) return Uint8Array.from(d as number[]);79+ console.warn('sim_metrics blob came back as unexpected type:', typeof d);80+ return null;81+}82+83+/** Run the full match and store its metrics. Deterministic: identical to what84+ * any viewer's replay computes (same engine build, same rngSeed derivation). */85+export async function computeSimMetrics(env: Env, simId: string): Promise<void> {86+ try {87+ const existing = await getStoredMetricsGz(env, simId);88+ if (existing !== null) return;89+ const sim = await db.getSimById(env.DB, simId, '');90+ if (!sim) return;91+ const refs = await db.getSimScripts(env.DB, simId);92+ const host = await getHost();93+ const config = JSON.parse(sim.config_json) as { tribeCount: number; mapSize: number; maxTicks: number };94+ const world = createWorld({ seed: sim.seed, ...config });95+ const vms = [];96+ for (const ref of refs) {97+ const source = (await db.getBlobSource(env.DB, ref.hash)) ?? '';98+ vms.push(99+ host.createTribeVm(source, {100+ gasPerTick: LIMITS.GAS_PER_TICK,101+ memoryLimitBytes: LIMITS.VM_MEMORY_LIMIT,102+ // DETERMINISM: must match the browser runner exactly: seed + ':' + slot.103+ rngSeed: `${sim.seed}:${ref.slot}`,104+ }),105+ );106+ }107+ const overruns = vms.map(() => 0);108+ const frames: MetricsFrame[] = [];109+ try {110+ while (!world.isOver()) {111+ const orders = new Map<number, Order[]>();112+ const alive = new Set(world.result().tribes.filter((t) => t.alive).map((t) => t.tribeId));113+ for (let slot = 0; slot < vms.length; slot++) {114+ if (!alive.has(slot)) continue;115+ const out = vms[slot]!.runTick(world.viewFor(slot));116+ if (out.overBudget) {117+ if (++overruns[slot]! >= LIMITS.MAX_CONSECUTIVE_OVERRUNS) world.forfeit(slot);118+ } else overruns[slot] = 0;119+ orders.set(slot, out.orders);120+ }121+ world.step(orders);122+ const m = world.latestMetrics();123+ if (m && (frames.length === 0 || m.tick !== frames[frames.length - 1]!.tick)) frames.push(m);124+ }125+ await storeMetrics(env, simId, { frames, result: world.result(), stateHash: world.stateHash() }, 'server');126+ console.log(JSON.stringify({ event: 'sim_metrics_computed', simId, frames: frames.length }));127+ } finally {128+ vms.forEach((vm) => vm.dispose());129+ }130+ } catch (err) {131+ // CPU-limit death lands here or kills us outright — the client fallback covers it.132+ console.warn('sim metrics precompute failed:', simId, err);133+ }134+}@@ -0,0 +1,24 @@
1+import { describe, expect, it } from 'vitest';2+import { gzipJson, gunzipJson, looksLikeStoredMetrics } from '../src/simrun';3+4+describe('sim metrics storage helpers', () => {5+ it('gzip/gunzip round-trips JSON', async () => {6+ const value = { frames: [{ tick: 10, tribes: [] }], result: { tribes: [] }, stateHash: 'ab' };7+ const gz = await gzipJson(value);8+ expect(gz.byteLength).toBeGreaterThan(0);9+ expect(await gunzipJson(gz)).toEqual(value);10+ });11+12+ it('shape check accepts a plausible payload and rejects junk', () => {13+ expect(14+ looksLikeStoredMetrics({ frames: [], result: { tribes: [] }, stateHash: 'x' }),15+ ).toBe(true);16+ expect(looksLikeStoredMetrics(null)).toBe(false);17+ expect(looksLikeStoredMetrics({ frames: 'no', result: { tribes: [] }, stateHash: 'x' })).toBe(false);18+ expect(looksLikeStoredMetrics({ frames: [], result: null, stateHash: 'x' })).toBe(false);19+ // over the frame-count bound (10k ticks / 10 + 1)20+ expect(21+ looksLikeStoredMetrics({ frames: new Array(1002).fill(0), result: { tribes: [] }, stateHash: 'x' }),22+ ).toBe(false);23+ });24+});@@ -39,6 +39,8 @@
3939 "dev": {4040 "name": "tribes-dev",4141 "routes": [{ "pattern": "dev.tribes.pub", "custom_domain": true }],42+ // Sim-metrics precompute runs a full match (~10-30s CPU) in waitUntil.43+ "limits": { "cpu_ms": 120000 },4244 "assets": {4345 "directory": "./web/dist",4446 "binding": "ASSETS",