Each tribe card carries its own compact feed; the big panel becomes WORLD (says, eliminations, forfeits, town-center falls, sightings). api.log lines now thread from the script host through frames as tick-stamped thoughts.
- commit
- e2d86013f78dca351a1c3b0a210d74ee005b6ff8
- parent
- 77137c277cbb2029c05a83aca3df70cb0fecc32d
- author
- Dami <dami.etoile@gmail.com>
| M | web/ | 15 | ++++- |
| M | web/ | 66 | +++++++++++++++++---- |
| M | web/ | 94 | +++++++++++++++++++++--------- |
| M | web/ | 21 | +++++- |
| M | web/ | 3 | + |
@@ -444,10 +444,19 @@ export async function createMockScriptHost(): Promise<ScriptHost> {
444444 const rng = makeRng(445445 [...opts.rngSeed].reduce((a, c) => (Math.imul(a, 31) + c.charCodeAt(0)) >>> 0, 7),446446 );447+ const THOUGHTS = [448+ 'scouting the northern treeline',449+ 'food dipping — rebalancing gatherers',450+ 'wall segment queued at the choke',451+ 'enemy last seen heading east',452+ ];447453 return {448- runTick(_view: TribeView): TickOutcome {449- // The mock world ignores orders; report plausible gas usage.450- return { orders: [], gasUsed: 40 + ri(rng, 60), overBudget: false, error: null };454+ runTick(view: TribeView): TickOutcome & { logs: string[] } {455+ // The mock world ignores orders; report plausible gas usage and456+ // emit an occasional api.log line so per-tribe feeds demo.457+ const logs =458+ view.tick % 40 === 0 ? [THOUGHTS[(view.tick / 40 + view.tribeId) % THOUGHTS.length]!] : [];459+ return { orders: [], gasUsed: 40 + ri(rng, 60), overBudget: false, error: null, logs };451460 },452461 dispose() {453462 /* nothing held */@@ -10,7 +10,7 @@ import { BUILDING_STATS, UNIT_STATS } from '@tribes/engine';
1010 import { api } from '../api';1111 import { h, clear } from '../dom';1212 import type { AppCtx } from '../main';13-import type { Frame, FrameBuilding, FrameUnit } from '../sim/frames';13+import type { Frame, FrameBuilding, FrameUnit, LogLine } from '../sim/frames';1414 import { SimRunner, type Speed } from '../sim/runner';1515 import { loadManifest } from '../render/manifest';1616 import { loadSprites, MapRenderer, drawFactionPortrait, TRIBE_COLORS, type SpriteAssets } from '../render/renderer';@@ -110,7 +110,12 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
110110 const progress = h('div', { class: 'progress-bar' }, progressFill);111111 const panels = sim.scripts.map((s) => makeTribePanel(s.slot, s.scriptName, assets));112112 const hashLine = h('p', { class: 'mono muted', style: 'font-size:0.75rem' });113- const feed = makeEventFeed(sim.scripts.map((s) => s.scriptName));113+ const names = sim.scripts.map((s) => s.scriptName);114+ // One compact feed per tribe (its events + its api.log internal monologue),115+ // plus a world feed for high-level story beats.116+ const tribeFeeds = sim.scripts.map(() => makeEventFeed(names, { compact: true }));117+ for (const [i, p] of panels.entries()) p.root.append(tribeFeeds[i]!.root);118+ const worldFeed = makeEventFeed(names, { title: 'World' });114119 115120 main.append(116121 h('div', { class: 'row', style: 'margin-bottom:0.6rem' },@@ -128,7 +133,7 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
128133 ),129134 hashLine,130135 ),131- h('div', { class: 'viewer-side' }, ...panels.map((p) => p.root), feed.root),136+ h('div', { class: 'viewer-side' }, ...panels.map((p) => p.root), worldFeed.root),132137 ),133138 );134139 @@ -143,8 +148,12 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
143148 latestFrame = frame;144149 dirty = true;145150 // Append here (not in the paint loop): frames can arrive faster than146- // paints, and each frame's events batch must reach the feed exactly once.147- feed.append(frame.events);151+ // paints, and each frame's batch must reach the feeds exactly once.152+ worldFeed.append(frame.world);153+ for (const [slot, tf] of tribeFeeds.entries()) {154+ tf.append(frame.eventsByTribe[slot] ?? []);155+ tf.appendLogs(frame.logsByTribe[slot] ?? []);156+ }148157 },149158 onDone(result, stateHash) {150159 playBtn.textContent = 'Play';@@ -200,7 +209,8 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
200209 latestFrame = null;201210 playBtn.disabled = false;202211 playBtn.textContent = 'Pause';203- feed.reset();212+ worldFeed.reset();213+ for (const tf of tribeFeeds) tf.reset();204214 runner.setPov(runner.pov); // keep pov205215 runner.restart();206216 };@@ -209,8 +219,7 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
209219 zoomBtn.textContent = zoomed ? 'Zoom fit' : 'Zoom 1:1';210220 };211221 povSel.onchange = () => {212- // Events are POV-filtered at the worker — the old feed no longer applies.213- feed.reset();222+ // POV only affects the map — feeds are per-tribe channels now.214223 runner.setPov(Number(povSel.value));215224 };216225 @@ -233,16 +242,32 @@ function buildViewer(main: HTMLElement, bundle: SimBundle, assets: SpriteAssets,
233242 // Event feed — MUD-style scrolling narration built from Frame.events.234243 235244 const FEED_MAX_LINES = 300;245+const COMPACT_FEED_MAX_LINES = 120;236246 237247 interface EventFeed {238248 root: HTMLElement;239249 append(events: SimEvent[]): void;250+ /** api.log lines — the tribe's internal monologue, rendered as » thoughts. */251+ appendLogs(lines: LogLine[]): void;240252 reset(): void;241253 }242254 243-function makeEventFeed(names: string[]): EventFeed {244- const body = h('div', { class: 'feed-body' });245- const root = h('div', { class: 'feed-panel' }, h('div', { class: 'feed-head' }, 'Events'), body);255+interface FeedOptions {256+ /** Panel header; omitted for compact in-card feeds. */257+ title?: string;258+ /** Small in-card variant (shorter, fewer retained lines). */259+ compact?: boolean;260+}261+262+function makeEventFeed(names: string[], opts: FeedOptions = {}): EventFeed {263+ const maxLines = opts.compact ? COMPACT_FEED_MAX_LINES : FEED_MAX_LINES;264+ const body = h('div', { class: opts.compact ? 'feed-body compact' : 'feed-body' });265+ const root = h(266+ 'div',267+ { class: opts.compact ? 'feed-panel in-card' : 'feed-panel' },268+ opts.title !== undefined ? h('div', { class: 'feed-head' }, opts.title) : null,269+ body,270+ );246271 // Auto-scroll to newest unless the user has scrolled up to read history.247272 let stick = true;248273 body.addEventListener('scroll', () => {@@ -283,13 +308,28 @@ function makeEventFeed(names: string[]): EventFeed {
283308 }284309 }285310 311+ const trim = () => {312+ while (body.childElementCount > maxLines) body.firstElementChild!.remove();313+ if (stick) body.scrollTop = body.scrollHeight;314+ };315+286316 return {287317 root,288318 append(events) {289319 if (events.length === 0) return;290320 for (const ev of events) body.append(line(ev));291- while (body.childElementCount > FEED_MAX_LINES) body.firstElementChild!.remove();292- if (stick) body.scrollTop = body.scrollHeight;321+ trim();322+ },323+ appendLogs(lines) {324+ if (lines.length === 0) return;325+ // Log text comes from scripts — text nodes only, never innerHTML.326+ for (const l of lines) {327+ body.append(328+ h('div', { class: 'feed-line feed-log' },329+ h('span', { class: 'feed-tick' }, `[t${l.t}] `), '» ', l.text),330+ );331+ }332+ trim();293333 },294334 reset() {295335 clear(body);@@ -19,7 +19,7 @@ import type { ArmyStance, EngineWorld, Job, Order, ScriptHost, SimEvent, TribeVi
1919 import { createWorld } from '@tribes/engine';2020 import { createScriptHost } from '@tribes/script-host';2121 import { createMockWorld, createMockScriptHost } from './dev-mock';22-import type { Facing, Frame, FrameBuilding, FrameTribe, FrameUnit, WorkerInMsg, WorkerOutMsg } from './sim/frames';22+import type { Facing, Frame, FrameBuilding, FrameTribe, FrameUnit, LogLine, WorkerInMsg, WorkerOutMsg } from './sim/frames';2323 import { log, setLogLevel } from './log';2424 2525 const post = (m: WorkerOutMsg) => self.postMessage(m);@@ -50,10 +50,14 @@ let done = false;
5050 /** id -> last position, for deriving facing/moving. */5151 const lastPos = new Map<number, { x: number; y: number; f: Facing }>();5252 let lastViews: TribeView[] = [];53-/** Cap on feed events buffered between posted frames (oldest dropped). */53+/** Cap per feed channel buffered between posted frames (oldest dropped). */5454 const EVENT_BUFFER_CAP = 200;55-/** POV-filtered events accumulated since the last posted frame. */56-let pendingEvents: SimEvent[] = [];55+/** Per-tribe event channels accumulated since the last posted frame. */56+let pendingByTribe: Record<number, SimEvent[]> = {};57+/** Per-tribe api.log lines (internal monologue) since the last posted frame. */58+let pendingLogs: Record<number, LogLine[]> = {};59+/** World channel: high-level summaries since the last posted frame. */60+let pendingWorld: SimEvent[] = [];5761 5862 async function init(msg: Extract<WorkerInMsg, { type: 'init' }>) {5963 if (msg.logLevel) setLogLevel(msg.logLevel);@@ -118,28 +122,42 @@ function refreshViews() {
118122 }119123 }120124 125+/** True for events the world feed carries: high-level story beats only. */126+function isWorldEvent(ev: SimEvent): boolean {127+ return (128+ ev.t === 'say' ||129+ ev.t === 'eliminated' ||130+ ev.t === 'forfeited' ||131+ ev.t === 'sighted' ||132+ (ev.t === 'building_destroyed' && ev.building === 'town_center')133+ );134+}135+121136 /**122- * Gather this tick's feed events from lastViews into pendingEvents, POV-123- * filtered. Omniscient (-1) unions every alive tribe's events; the same event124- * is delivered in multiple views, so dedupe by JSON identity (safe across125- * ticks: every event carries its tick). Call exactly once per step.137+ * Gather this tick's feed channels from lastViews. Every viewer is a138+ * spectator (bundles are public), so all tribes' channels are always sent —139+ * the POV setting only affects the map. Per-tribe channel = that tribe's own140+ * fog-filtered events minus OTHER tribes' say broadcasts (those are world141+ * material). World = deduped high-level events across all views.142+ * Call exactly once per step.126143 */127144 function collectEvents() {128- if (pov >= 0) {129- const v = lastViews.find((view) => view.tribeId === pov);130- if (v) pendingEvents.push(...v.events);131- } else {132- const seen = new Set<string>();133- for (const v of lastViews) {134- for (const ev of v.events) {145+ const seen = new Set<string>();146+ for (const v of lastViews) {147+ const mine = (pendingByTribe[v.tribeId] ??= []);148+ for (const ev of v.events) {149+ if (ev.t !== 'say' || ev.tribe === v.tribeId) mine.push(ev);150+ if (isWorldEvent(ev)) {135151 const key = JSON.stringify(ev);136- if (seen.has(key)) continue;137- seen.add(key);138- pendingEvents.push(ev);152+ if (!seen.has(key)) {153+ seen.add(key);154+ pendingWorld.push(ev);155+ }139156 }140157 }158+ if (mine.length > EVENT_BUFFER_CAP) pendingByTribe[v.tribeId] = mine.slice(-EVENT_BUFFER_CAP);141159 }142- if (pendingEvents.length > EVENT_BUFFER_CAP) pendingEvents = pendingEvents.slice(-EVENT_BUFFER_CAP);160+ if (pendingWorld.length > EVENT_BUFFER_CAP) pendingWorld = pendingWorld.slice(-EVENT_BUFFER_CAP);143161 }144162 145163 function tickOnce() {@@ -157,6 +175,14 @@ function tickOnce() {
157175 const outcome = st.vm.runTick(view);158176 st.overBudget = outcome.overBudget;159177 if (outcome.error) st.error = outcome.error;178+ // api.log lines are the tribe's internal monologue — thread them to the179+ // per-tribe feed, tick-stamped.180+ const logs = (outcome as { logs?: string[] }).logs;181+ if (logs !== undefined && logs.length > 0) {182+ const mine = (pendingLogs[view.tribeId] ??= []);183+ for (const text of logs) mine.push({ t: world.tick, text });184+ if (mine.length > EVENT_BUFFER_CAP) pendingLogs[view.tribeId] = mine.slice(-EVENT_BUFFER_CAP);185+ }160186 if (outcome.overBudget) {161187 st.consecutiveOverruns++;162188 if (st.consecutiveOverruns >= LIMITS.MAX_CONSECUTIVE_OVERRUNS) {@@ -277,11 +303,27 @@ function buildFrame(): Frame {
277303 278304 units.sort((a, b) => a.id - b.id);279305 buildings.sort((a, b) => a.id - b.id);280- // Drain the event buffer into this frame — events accumulated across281- // sampled-out ticks ride along with the next posted frame.282- const events = pendingEvents;283- pendingEvents = [];284- return { tick: w.tick, maxTicks: w.config.maxTicks, pov, terrain, mapSize, units, buildings, tribes, events };306+ // Drain all feed channels into this frame — anything accumulated across307+ // sampled-out ticks rides along with the next posted frame.308+ const eventsByTribe = pendingByTribe;309+ const logsByTribe = pendingLogs;310+ const worldFeed = pendingWorld;311+ pendingByTribe = {};312+ pendingLogs = {};313+ pendingWorld = [];314+ return {315+ tick: w.tick,316+ maxTicks: w.config.maxTicks,317+ pov,318+ terrain,319+ mapSize,320+ units,321+ buildings,322+ tribes,323+ eventsByTribe,324+ logsByTribe,325+ world: worldFeed,326+ };285327 }286328 287329 function emitFrame() {@@ -341,10 +383,8 @@ self.onmessage = (e: MessageEvent) => {
341383 sampleEvery = Math.max(1, msg.sampleEvery);342384 pump();343385 } else if (msg.type === 'setPov') {386+ // POV only affects the map now — feed channels are always per-tribe.344387 pov = msg.pov;345- // Events buffered under the old POV no longer match the filter; the page346- // clears its feed on POV switch, so drop them rather than mislabel them.347- pendingEvents = [];348388 // Re-emit the current frame so a paused viewer updates immediately.349389 if (world) emitFrame();350390 }@@ -50,6 +50,12 @@ export interface FrameTribe {
5050 error: string | null;5151 }5252 53+/** One api.log() line, tick-stamped by the worker when it was drained. */54+export interface LogLine {55+ t: number;56+ text: string;57+}58+5359 export interface Frame {5460 tick: number;5561 maxTicks: number;@@ -62,11 +68,18 @@ export interface Frame {
6268 buildings: FrameBuilding[];6369 tribes: FrameTribe[];6470 /**65- * POV-filtered feed events accumulated since the previous posted frame66- * (frames are sampled every N ticks; events between samples are buffered in67- * the worker so none are lost, oldest dropped past ~200 in flight).71+ * Feed channels accumulated since the previous posted frame (frames are72+ * sampled every N ticks; the worker buffers between samples so nothing is73+ * lost, oldest dropped past a cap).74+ * - eventsByTribe[t]: tribe t's own fog-filtered events, minus other75+ * tribes' say broadcasts (those live in `world`).76+ * - logsByTribe[t]: tribe t's api.log() lines — its internal monologue.77+ * - world: high-level summaries — says, eliminations, forfeits,78+ * town-center destructions, sightings.6879 */69- events: SimEvent[];80+ eventsByTribe: Record<number, SimEvent[]>;81+ logsByTribe: Record<number, LogLine[]>;82+ world: SimEvent[];7083 }7184 7285 export type WorkerInMsg =@@ -211,6 +211,9 @@ canvas.map.zoomed { width: 2048px; max-width: none; }
211211 }212212 .feed-say .feed-say-text { color: var(--accent); font-style: italic; }213213 .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; }214217 215218 /* ------- map tooltip ------- */216219 .map-tooltip {