@polycode-projects/the-mechanical-code-talker 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,752 @@
1
+ // mud-viz.mjs — mud.html: the self-contained proof of the shared,
2
+ // multi-character shape PLAN_MUD.md's "Demo phase" section describes. Four
3
+ // burrowing animals (mole-1, vole-1, badger-2, groundhog-1 — mud-garden.jsonl
4
+ // ships all four) each get their own window over ONE shared live world
5
+ // (mud-browser-entry.mjs's createMudSession); a fifth, omniscient view — the
6
+ // soil cross-section world map — sits in the middle with no fog of war at
7
+ // all, the one deliberate exception PLAN_MUD.md names.
8
+ //
9
+ // Shaped after adventure-viz.mjs/spider-fly-viz.mjs: one inlined <style>
10
+ // over viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
11
+ // engine arriving via a sibling <script src="./mud-browser.bundle.js">
12
+ // (mirroring adventure-viz.mjs's own worldPayload-embedding rationale — the
13
+ // world's canonical source is a Node-only gzipped JSONL shard the browser
14
+ // cannot read, so it is read ONCE at build time through the real worlds-pack
15
+ // provider and embedded as page data). `createTicker` and a small set of
16
+ // self-contained, `.toString()`-splice-safe room-scene helpers are spliced
17
+ // into the inline script exactly the way spider-fly-viz.mjs splices its own
18
+ // render-glue — `roomSceneObjects`/`spriteClassForObject`/`visibleRoomOf`/
19
+ // `roomKindForRoom` are REUSED directly from adventure-viz.mjs rather than
20
+ // re-derived: mud-garden ships no individual named "player" (the whole point
21
+ // of the multi-character demo), so those functions' own hardcoded "player"
22
+ // exclusion never fires for a mud character — every character reads back as
23
+ // an ordinary visible object of its room until this page's own code filters
24
+ // the CURRENT viewing character out by name (mudRoomSceneObjects, below).
25
+ //
26
+ // A room's graphic is a rendering of worldDigestRows'/roomAffordances' own
27
+ // text digest, never a replacement for it (PLAN_MUD.md's own "Room view"
28
+ // spec) — this page keeps the layout intentionally simpler than Ashcombe
29
+ // Hall's wall/floor stacking (adventure-viz.mjs's roomSceneLayout): a soil-
30
+ // toned canvas backdrop (roomKindForRoom picks outdoor/underground), the
31
+ // viewing character's own sprite drawn center-low, every room-mate and loose
32
+ // object laid out as a plain wrapped tray of sprites above it. PLAN_MUD.md
33
+ // specifies the canvas+sprite-layer TECHNIQUE and the ground-truth-vs-
34
+ // rendering relationship, not stacking physics, so this is this page's own
35
+ // engineering call on the gap PLAN_MUD.md leaves open, not a cut from its
36
+ // spec.
37
+ //
38
+ // FIVE ticker instances (viz-ticker.mjs's createTicker): one per window, so
39
+ // each can play/pause/step independently, plus the rail's own "auto" control
40
+ // that just calls .play()/.pause() on the four window tickers at once. Every
41
+ // tick, from ANY window, is funneled through one shared async queue
42
+ // (serializeTick, spliced below) so two characters' turns can never
43
+ // interleave their reads/writes of the one shared memoryDir — mud-browser-
44
+ // entry.mjs's own header names this as the caller's responsibility, and this
45
+ // is where that responsibility is discharged. The queue is also what makes
46
+ // the rail's GLOBAL turn counter well-defined: it increments in the exact
47
+ // order turns actually executed, never a race between windows.
48
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
49
+ import { createTicker } from "./viz-ticker.mjs";
50
+ import {
51
+ roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows,
52
+ visibleRoomOf, roomKindForRoom, factsForSubject,
53
+ } from "./adventure-viz.mjs";
54
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
55
+
56
+ const DEFAULT_TITLE = "tmct — the mud";
57
+ // No display-face embedding pipeline exists anywhere in this project yet
58
+ // (grep turns up no @font-face/font-display in any *-viz.mjs) — Fraunces
59
+ // itself never ships, so DISPLAY_STACK is SERIF_STACK's own web-safe serif
60
+ // fallback, named separately only so a later session that DOES add a font
61
+ // pipeline has one obvious constant to point at Fraunces instead of typing
62
+ // "Georgia" into headings by hand.
63
+ const DISPLAY_STACK = SERIF_STACK;
64
+ const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
65
+
66
+ const ROOT_ROOM = "garden";
67
+ const SLOTS = ["nw", "ne", "sw", "se"];
68
+ const DEFAULT_DELAY_MS = 650;
69
+ const DEFAULT_MAX_TURNS = 400;
70
+
71
+ const MUD_NOTE_LINES = [
72
+ "Four burrowing animals share one world here. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole map except you, watching from the middle.",
73
+ `This is a MUD, short for Multi Underground creature Dig. The name nods to MUDII (mudii.co.uk), one of the first multiplayer text games. The dig-your-own-rooms idea came from a skim of Wikipedia's Colossal Cave Adventure article, the game that started the genre.`,
74
+ ];
75
+
76
+ /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
77
+ * "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
78
+ * contained, `.toString()`-splice safe. */
79
+ export function speciesOfCharacter(id) {
80
+ return String(id).replace(/-\d+$/, "");
81
+ }
82
+
83
+ /** Every object visible in `here` EXCEPT `viewer` itself — `roomSceneObjects`
84
+ * (adventure-viz.mjs) only ever excludes the literal id "player", which no
85
+ * mud-garden character is ever named, so every OTHER character sharing the
86
+ * room (and every loose object) comes back exactly like any prop; this page
87
+ * draws the viewer's own sprite separately, so it is the one subject this
88
+ * wrapper drops. Pure, self-contained (roomSceneObjects is spliced
89
+ * alongside it). */
90
+ export function mudRoomSceneObjects(rows, state, here, viewer) {
91
+ return roomSceneObjects(rows, state, here).filter((o) => o.subject !== viewer);
92
+ }
93
+
94
+ /** Every object `character` carries, sorted, each with its sprite class —
95
+ * mud's own version of adventure-viz.mjs's carriedItems, parametrized by
96
+ * character instead of hardcoded to "player" (mud-garden ships no such
97
+ * individual). Pure, self-contained. */
98
+ export function carriedItemsFor(rows, state, character) {
99
+ return [...state.placements]
100
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === character)
101
+ .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
102
+ .sort((a, b) => a.subject.localeCompare(b.subject));
103
+ }
104
+
105
+ /** Every room's depth level, `Map<roomId, number>`, BFS from `root` at level
106
+ * 0: an "up"/"down" exit moves the level by ∓ 1, any other direction
107
+ * keeps the level unchanged — the soil cross-section's own row assignment,
108
+ * and each window's own minimap filter ("this character's own level only").
109
+ * A room unreachable from `root` (should not happen — every dug room writes
110
+ * a two-way exit back) is simply absent from the map rather than guessed
111
+ * at. Pure, self-contained. */
112
+ export function levelsOf(state, root = "garden") {
113
+ const levels = new Map([[root, 0]]);
114
+ const queue = [root];
115
+ while (queue.length) {
116
+ const room = queue.shift();
117
+ const level = levels.get(room);
118
+ for (const [direction, target] of state.exits.get(room) ?? []) {
119
+ if (levels.has(target)) continue;
120
+ const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
121
+ levels.set(target, level + delta);
122
+ queue.push(target);
123
+ }
124
+ }
125
+ return levels;
126
+ }
127
+
128
+ /** `levels` grouped into `{ level, rooms: [roomId...] }` rows, deepest last
129
+ * so a caller can lay the soil cross-section out top (level 0) to bottom —
130
+ * rooms within a level sort by id for a stable, deterministic column order.
131
+ * Pure. */
132
+ export function levelBands(levels) {
133
+ const byLevel = new Map();
134
+ for (const [room, level] of levels) {
135
+ if (!byLevel.has(level)) byLevel.set(level, []);
136
+ byLevel.get(level).push(room);
137
+ }
138
+ return [...byLevel.entries()]
139
+ .sort((a, b) => b[0] - a[0])
140
+ .map(([level, rooms]) => ({ level, rooms: rooms.sort() }));
141
+ }
142
+
143
+ /** Which characters currently stand in `room` — the omniscient world map's
144
+ * own per-room roster, and a per-window minimap's own (visited-only)
145
+ * roster. Pure. */
146
+ export function charactersInRoom(state, room, characters) {
147
+ return characters.filter((c) => state.placements.get(c)?.object === room);
148
+ }
149
+
150
+ function slugForSlot(slot) {
151
+ return `window-${slot}`;
152
+ }
153
+
154
+ /** The self-contained mud.html page. Pure — identical output for identical
155
+ * input; every other piece of state (the live world, chat, ticks) is
156
+ * computed in the browser once the sibling bundle loads.
157
+ * `characters` is `[{ id, slot, species }]`, one entry per window, in NW,
158
+ * NE, SW, SE order. `worldPayload` is `{ name, facts, rules, opening }`,
159
+ * read once at build time through the real worlds-pack provider (see this
160
+ * module's own header). `spriteTemplates` is the large-tier sprite set
161
+ * (data/sprites-large/*.toml) so mole/vole/badger/groundhog/meerkat all
162
+ * resolve their own art instead of falling back to the flat animal icon.
163
+ * `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
164
+ * the per-species mass/speed/dig-reach reference table the Creature Stats
165
+ * panel reads for flavor (see this module's header on why the live
166
+ * simulation does not yet act on speed/dig-reach itself).
167
+ * `engineBundleJs` inlines the built mud-browser bundle instead of the
168
+ * sibling `<script src>`, mirroring spider-fly-viz.mjs's own standalone-
169
+ * export knob; default empty keeps the site build's sibling-file
170
+ * arrangement unchanged. */
171
+ export function renderMudHtml({
172
+ title = DEFAULT_TITLE,
173
+ worldPayload,
174
+ characters = [],
175
+ spriteTemplates = [],
176
+ mudConfig = DEFAULT_GAME_CONFIG.mud,
177
+ engineBundleJs = "",
178
+ } = {}) {
179
+ const pageData = embedJson({
180
+ worldPayload, characters, spriteTemplates, mudConfig,
181
+ rootRoom: ROOT_ROOM,
182
+ defaultDelayMs: DEFAULT_DELAY_MS,
183
+ defaultMaxTurns: DEFAULT_MAX_TURNS,
184
+ });
185
+
186
+ const windowHtml = characters.map((c) => windowMarkup(c)).join("\n");
187
+
188
+ return `<!doctype html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="utf-8">
192
+ <meta name="viewport" content="width=device-width, initial-scale=1">
193
+ <title>${escapeHtml(title)}</title>
194
+ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
195
+ <link rel="icon" href="./favicon.ico" sizes="any">
196
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png">`}
197
+ <style>
198
+ ${THEME_TOKENS_CSS}
199
+ ${MUD_STYLE}
200
+ </style>
201
+ </head>
202
+ <body>
203
+ <main>
204
+ <div class="eyebrow">tmct &middot; mud</div>
205
+ <h1>Multiple actors, one shared world</h1>
206
+ <div class="mud-stage" id="mudStage">
207
+ ${windowHtml}
208
+ <div class="world-map" id="worldMap" aria-label="the whole world, every level, every character">
209
+ <div class="world-map-head">
210
+ <span class="world-map-title">the whole burrow</span>
211
+ <span class="mono world-map-turn" id="worldMapTurn">turn 0</span>
212
+ </div>
213
+ <div class="world-map-bands" id="worldMapBands"></div>
214
+ </div>
215
+ </div>
216
+ <div class="rail" id="rail" aria-label="simulation controls">
217
+ <div class="rail-row">
218
+ <button type="button" id="autoToggle" aria-pressed="false">&#9654; auto</button>
219
+ <span class="mono rail-turns" id="globalTurnCount">turns: 0</span>
220
+ <label class="rail-slider">delay
221
+ <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
222
+ <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
223
+ </label>
224
+ <label class="rail-slider">max turns
225
+ <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
226
+ <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
227
+ </label>
228
+ <button type="button" id="resetBtn">reset</button>
229
+ </div>
230
+ <div class="rail-note mud-note">
231
+ <p>${escapeHtml(MUD_NOTE_LINES[0])}</p>
232
+ <p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
233
+ </div>
234
+ </div>
235
+ </main>
236
+ <script>
237
+ const MUD_PAGE_DATA = ${pageData};
238
+ </script>
239
+ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./mud-browser.bundle.js"></script>`}
240
+ <script>
241
+ ${embedScriptText(pageScript())}
242
+ </script>
243
+ </body>
244
+ </html>
245
+ `;
246
+ }
247
+
248
+ function windowMarkup({ id, slot, species }) {
249
+ const w = slugForSlot(slot);
250
+ return `<div class="mud-window" id="${w}" data-slot="${escapeHtml(slot)}" data-character="${escapeHtml(id)}">
251
+ <div class="mud-window-head">
252
+ <h2>${escapeHtml(species)} <span class="mono char-id">${escapeHtml(id)}</span></h2>
253
+ <span class="mono window-turn" id="${w}-turn">turn 0</span>
254
+ </div>
255
+ <div class="room-view" id="${w}-room">
256
+ <canvas id="${w}-canvas" width="360" height="220" aria-hidden="true"></canvas>
257
+ <div class="sprite-layer" id="${w}-sprites"></div>
258
+ <div class="speech-bubble" id="${w}-bubble" hidden></div>
259
+ <div class="dig-flourish" id="${w}-flourish" hidden></div>
260
+ </div>
261
+ <p class="room-caption" id="${w}-caption"></p>
262
+ <div class="window-columns">
263
+ <div class="pouch" id="${w}-pouch" aria-label="pouch">
264
+ <h3>pouch</h3>
265
+ <ul class="pouch-list" id="${w}-pouch-list"></ul>
266
+ <p class="mono stat-line" id="${w}-stats"></p>
267
+ </div>
268
+ <div class="minimap" aria-label="what this character has discovered">
269
+ <h3>known ground</h3>
270
+ <div class="minimap-rooms" id="${w}-minimap"></div>
271
+ </div>
272
+ </div>
273
+ <div class="window-controls controls-row">
274
+ <button type="button" id="${w}-play" disabled>&#9654; play</button>
275
+ <button type="button" id="${w}-step" disabled>step</button>
276
+ <span class="mono" id="${w}-count">0 turns</span>
277
+ </div>
278
+ <div class="chat">
279
+ <div class="chatlog" id="${w}-chatlog" aria-live="polite"></div>
280
+ <form class="chatask" id="${w}-chatform">
281
+ <span class="prompt mono">tmct&gt;</span>
282
+ <input id="${w}-chatq" type="text" placeholder="dig north" aria-label="talk to ${escapeHtml(id)}" disabled>
283
+ </form>
284
+ <div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands">
285
+ <button type="button" class="pill" data-fill="look" disabled>look</button>
286
+ <button type="button" class="pill" data-fill="what do you know about food" disabled>what do you know about food</button>
287
+ <button type="button" class="pill" data-fill="dig north" disabled>dig north</button>
288
+ <button type="button" class="pill" data-fill="dig down" disabled>dig down</button>
289
+ </div>
290
+ </div>
291
+ </div>`;
292
+ }
293
+
294
+ const MUD_STYLE = `
295
+ :root {
296
+ --soil-deep: #2B1D14; --soil-mid: #4A3324; --soil-light: #7A5A3D;
297
+ --root-moss: #6B7A4F; --parchment: #EFE6D8; --mud-ink: #2A211A; --burrow-glow: #E8A33D;
298
+ }
299
+ html { background: var(--soil-deep); }
300
+ body { margin: 0; background: linear-gradient(180deg, var(--root-moss) 0%, var(--soil-light) 18%, var(--soil-mid) 55%, var(--soil-deep) 100%) fixed; color: var(--mud-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
301
+ .mono { font-family: ${MONO_STACK}; }
302
+ main { max-width: 1280px; margin: 0 auto; padding: 1.4rem 1.2rem 2.4rem; }
303
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--parchment); opacity: .85; }
304
+ h1 { font-family: ${DISPLAY_STACK}; font-weight: 600; font-size: 1.7rem; margin: .25rem 0 1.1rem; color: var(--parchment); text-wrap: balance; }
305
+ h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
306
+ h3 { font-family: ${DISPLAY_STACK}; font-size: .78rem; margin: 0 0 .35rem; text-transform: uppercase; letter-spacing: .04em; color: var(--soil-mid); }
307
+ button { font: inherit; color: inherit; background: none; cursor: pointer; }
308
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--burrow-glow); outline-offset: 2px; }
309
+ button:disabled { opacity: .45; cursor: default; }
310
+
311
+ .mud-stage {
312
+ position: relative;
313
+ display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; gap: 1rem;
314
+ }
315
+ @media (max-width: 760px) { .mud-stage { grid-template-columns: 1fr; } .world-map { position: static; margin: .5rem 0; transform: none; } }
316
+
317
+ .mud-window {
318
+ background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px;
319
+ box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
320
+ padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem; min-width: 0;
321
+ }
322
+ .mud-window-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
323
+ .char-id { font-size: .68rem; color: var(--soil-mid); }
324
+ .window-turn { font-size: .7rem; color: var(--soil-mid); }
325
+
326
+ .room-view { position: relative; border-radius: 3px; overflow: hidden; border: 1px solid var(--soil-mid); }
327
+ .room-view canvas { display: block; width: 100%; height: auto; }
328
+ .sprite-layer { position: absolute; inset: 0; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 2%; padding: 4%; box-sizing: border-box; }
329
+ .sprite { width: 15%; min-width: 28px; transition: transform .2s ease; }
330
+ .sprite.self { width: 20%; min-width: 34px; order: -1; }
331
+ .sprite svg { width: 100%; display: block; }
332
+ @media (prefers-reduced-motion: reduce) { .sprite { transition: none; } }
333
+
334
+ .speech-bubble {
335
+ position: absolute; top: 6%; left: 6%; max-width: 78%;
336
+ background: var(--parchment); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 8px;
337
+ padding: .3rem .55rem; font-size: .72rem; line-height: 1.3; box-shadow: 0 2px 4px rgba(0,0,0,.25);
338
+ opacity: 0; transition: opacity .35s ease;
339
+ }
340
+ .speech-bubble.shown { opacity: 1; }
341
+ @media (prefers-reduced-motion: reduce) { .speech-bubble { transition: none; } }
342
+
343
+ .dig-flourish { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50% 70%, var(--burrow-glow) 0%, transparent 60%); transition: opacity .5s ease; }
344
+ .dig-flourish.shown { opacity: .55; }
345
+ @media (prefers-reduced-motion: reduce) { .dig-flourish { transition: none; opacity: 0 !important; } }
346
+
347
+ .room-caption { font-size: .8rem; margin: 0; color: var(--soil-mid); }
348
+
349
+ .window-columns { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
350
+ .pouch, .minimap { background: rgba(255,255,255,.35); border: 1px solid var(--soil-light); border-radius: 3px; padding: .4rem .5rem; }
351
+ .pouch-list { list-style: none; margin: 0; padding: 0; font-size: .74rem; display: flex; flex-direction: column; gap: .15rem; }
352
+ .pouch-list:empty::after { content: "carrying nothing"; color: var(--soil-mid); font-style: italic; }
353
+ .stat-line { margin: .3rem 0 0; font-size: .64rem; color: var(--soil-mid); }
354
+ .minimap-rooms { display: flex; flex-wrap: wrap; gap: .25rem; font-family: ${MONO_STACK}; font-size: .62rem; }
355
+ .minimap-room { padding: .12rem .35rem; background: var(--soil-light); color: var(--parchment); border-radius: 2px; }
356
+ .minimap-room.current { background: var(--burrow-glow); color: var(--mud-ink); font-weight: 600; }
357
+ .minimap-rooms:empty::after { content: "nothing dug yet"; color: var(--soil-mid); font-family: ${SANS_STACK}; font-size: .68rem; font-style: italic; }
358
+
359
+ .window-controls button, .rail button { font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .02em; padding: .3rem .6rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: var(--parchment); }
360
+ .window-controls button:hover:not(:disabled), .rail button:hover:not(:disabled) { border-color: var(--burrow-glow); }
361
+ .window-controls { gap: .4rem; }
362
+ .window-controls .mono { margin-left: auto; font-size: .7rem; }
363
+
364
+ .chat { display: flex; flex-direction: column; gap: .35rem; }
365
+ .chatlog { display: flex; flex-direction: column; gap: .3rem; max-height: 140px; overflow-y: auto; }
366
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--soil-mid); }
367
+ .chatlog .u::before { content: "tmct> "; color: var(--burrow-glow); }
368
+ .chatlog .a { font-size: .76rem; line-height: 1.35; }
369
+ .chatask { display: flex; align-items: center; gap: .4rem; }
370
+ .chatask .prompt { color: var(--burrow-glow); font-size: .72rem; }
371
+ .chatask input { flex: 1; min-width: 0; font-family: ${MONO_STACK}; font-size: .72rem; background: rgba(255,255,255,.6); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 2px; padding: .28rem .45rem; box-sizing: border-box; }
372
+ .chatpills { display: flex; flex-wrap: wrap; gap: .25rem; }
373
+ .pill { font-family: ${MONO_STACK}; font-size: .62rem; padding: .15rem .5rem; border: 1px solid var(--soil-mid); border-radius: 99px; background: rgba(255,255,255,.5); }
374
+ .pill:hover:not(:disabled) { border-color: var(--burrow-glow); }
375
+
376
+ .world-map {
377
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5;
378
+ width: min(420px, 90%); background: var(--soil-deep); color: var(--parchment);
379
+ border: 2px solid var(--burrow-glow); border-radius: 6px; padding: .5rem .6rem .7rem;
380
+ box-shadow: 0 6px 24px rgba(0,0,0,.45);
381
+ }
382
+ .world-map-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .35rem; }
383
+ .world-map-title { font-family: ${DISPLAY_STACK}; font-size: .82rem; letter-spacing: .02em; }
384
+ .world-map-turn { font-size: .66rem; opacity: .8; }
385
+ .world-map-bands { display: flex; flex-direction: column; gap: 2px; }
386
+ .map-band { display: flex; align-items: center; gap: .35rem; padding: .3rem .4rem; border-radius: 2px; }
387
+ .map-band .band-level { font-family: ${MONO_STACK}; font-size: .6rem; width: 2.4em; opacity: .75; }
388
+ .map-band .band-rooms { display: flex; flex-wrap: wrap; gap: .3rem; flex: 1; }
389
+ .map-room { font-family: ${MONO_STACK}; font-size: .6rem; padding: .12rem .4rem; border-radius: 2px; background: rgba(239,230,216,.14); }
390
+ .map-room .room-cast { margin-left: .25rem; opacity: .85; }
391
+ .map-room.freshly-dug { animation: dig-pulse 1.1s ease-out; }
392
+ @keyframes dig-pulse { 0% { box-shadow: 0 0 0 0 var(--burrow-glow); } 100% { box-shadow: 0 0 0 8px transparent; } }
393
+ @media (prefers-reduced-motion: reduce) { .map-room.freshly-dug { animation: none; } }
394
+
395
+ .rail { margin-top: 1.2rem; background: var(--parchment); border: 1px solid var(--soil-mid); border-radius: 4px; padding: .7rem .9rem; }
396
+ .rail-row { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; }
397
+ .rail-turns { font-size: .78rem; }
398
+ .rail-slider { display: flex; align-items: center; gap: .35rem; font-size: .68rem; color: var(--soil-mid); }
399
+ .rail-slider input[type="range"] { accent-color: var(--burrow-glow); }
400
+ .rail-note { margin-top: .6rem; padding-top: .55rem; border-top: 1px solid var(--soil-light); font-size: .78rem; color: var(--soil-mid); }
401
+ .rail-note p { margin: 0 0 .4rem; }
402
+ .rail-note p:last-child { margin-bottom: 0; }
403
+ `;
404
+
405
+ /** The inlined page script, as a plain function body handed to
406
+ * embedScriptText — mirrors spider-fly-viz.mjs's own `(function () {
407
+ * "use strict"; ... })()` IIFE shape, with every spliced helper listed at
408
+ * the top of the closure exactly like that page's own const bindings. */
409
+ function pageScript() {
410
+ return `(function () {
411
+ "use strict";
412
+ const DATA = MUD_PAGE_DATA;
413
+ const createTicker = ${createTicker.toString()};
414
+ const esc = ${escapeHtml.toString()};
415
+ const speciesOfCharacter = ${speciesOfCharacter.toString()};
416
+ const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
417
+ const carriedItemsFor = ${carriedItemsFor.toString()};
418
+ const levelsOf = ${levelsOf.toString()};
419
+ const levelBands = ${levelBands.toString()};
420
+ const charactersInRoom = ${charactersInRoom.toString()};
421
+ const roomSceneObjects = ${roomSceneObjects.toString()};
422
+ const scenePlacement = ${scenePlacement.toString()};
423
+ const spriteClassForObject = ${spriteClassForObject.toString()};
424
+ const spriteAncestryRows = ${spriteAncestryRows.toString()};
425
+ const visibleRoomOf = ${visibleRoomOf.toString()};
426
+ const roomKindForRoom = ${roomKindForRoom.toString()};
427
+ const factsForSubject = ${factsForSubject.toString()};
428
+
429
+ const el = (id) => document.getElementById(id);
430
+ const characters = DATA.characters;
431
+ const characterIds = characters.map((c) => c.id);
432
+ const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
433
+
434
+ // ---- the one shared tick queue -----------------------------------------
435
+ // Every window's ticker calls into this instead of session.windows[...]
436
+ // directly, so two characters' turns can never interleave their reads and
437
+ // writes of the one shared memoryDir, and the global turn counter always
438
+ // increments in real execution order.
439
+ let tickChain = Promise.resolve();
440
+ function serializeTick(fn) {
441
+ const run = tickChain.then(fn, fn);
442
+ tickChain = run.catch(function () {});
443
+ return run;
444
+ }
445
+
446
+ let session = null;
447
+ let globalTurn = 0;
448
+ let maxTurns = DATA.defaultMaxTurns;
449
+ let delayMs = DATA.defaultDelayMs;
450
+ const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
451
+ const liveWait = function () { return wait(delayMs); };
452
+
453
+ const knownRoomIds = new Set();
454
+ let freshlyDugRoom = null;
455
+ const speechBubbles = new Map(); // room -> { character, text, expiresAtTurn }
456
+
457
+ const tickers = {};
458
+ let autoOn = false;
459
+
460
+ function hasNext() { return globalTurn < maxTurns; }
461
+
462
+ async function runOneTurn(character) {
463
+ return serializeTick(async function () {
464
+ globalTurn += 1;
465
+ const result = await session.windows[character].autoplayTick(globalTurn);
466
+ afterEngineTurn(character, result);
467
+ return result;
468
+ });
469
+ }
470
+
471
+ function afterEngineTurn(character, result) {
472
+ if (!result || !result.room) { renderAll(); return; }
473
+ if (!knownRoomIds.has(result.room)) knownRoomIds.add(result.room);
474
+ if (result.roomAfter && !knownRoomIds.has(result.roomAfter)) {
475
+ freshlyDugRoom = result.roomAfter;
476
+ knownRoomIds.add(result.roomAfter);
477
+ }
478
+ const ask = (result.actions || []).find(function (a) { return a.step === "investigate" && a.kind === "ask"; });
479
+ if (ask) {
480
+ speechBubbles.set(result.room, {
481
+ character: character, text: character + " asks about food \\u2014 hears about " + ask.thing + ".",
482
+ expiresAtTurn: globalTurn + 1,
483
+ });
484
+ }
485
+ renderAll();
486
+ }
487
+
488
+ function ensureTicker(character) {
489
+ if (tickers[character]) return tickers[character];
490
+ const w = windowIdFor(character);
491
+ const playBtn = el(w + "-play");
492
+ const stepBtn = el(w + "-step");
493
+ const ticker = createTicker({
494
+ onTick: function () { return runOneTurn(character); },
495
+ onRender: function (state) {
496
+ playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
497
+ },
498
+ hasNext: hasNext,
499
+ wait: liveWait,
500
+ });
501
+ playBtn.addEventListener("click", function () { ticker.play(); });
502
+ stepBtn.addEventListener("click", function () { ticker.stepOnce(); });
503
+ tickers[character] = ticker;
504
+ return ticker;
505
+ }
506
+
507
+ function windowIdFor(character) {
508
+ const c = characters.find(function (x) { return x.id === character; });
509
+ return "window-" + c.slot;
510
+ }
511
+
512
+ // ---- chat docks ---------------------------------------------------------
513
+ function wireChat(character) {
514
+ const w = windowIdFor(character);
515
+ const form = el(w + "-chatform");
516
+ const input = el(w + "-chatq");
517
+ const log = el(w + "-chatlog");
518
+ const pillsEl = el(w + "-chatpills");
519
+ function append(cls, text) {
520
+ const d = document.createElement("div");
521
+ d.className = cls;
522
+ d.textContent = text;
523
+ log.appendChild(d);
524
+ log.scrollTop = log.scrollHeight;
525
+ }
526
+ form.addEventListener("submit", function (e) {
527
+ e.preventDefault();
528
+ const line = input.value.trim();
529
+ if (!line) return;
530
+ input.value = "";
531
+ append("u", line);
532
+ serializeTick(function () { return session.windows[character].turn(line); }).then(function (res) {
533
+ append("a", res.answer);
534
+ renderAll();
535
+ });
536
+ });
537
+ for (const pill of pillsEl.querySelectorAll(".pill")) {
538
+ pill.addEventListener("click", function () {
539
+ input.value = pill.getAttribute("data-fill");
540
+ input.focus();
541
+ });
542
+ }
543
+ }
544
+
545
+ // ---- room-view rendering -------------------------------------------------
546
+ function roomKindTint(kind) {
547
+ if (kind === "outdoor") return "var(--root-moss)";
548
+ return "var(--soil-mid)";
549
+ }
550
+
551
+ function spriteSvgFor(species, rows) {
552
+ if (window.tmctMud && window.tmctMud.resolveSpriteAsset) {
553
+ return window.tmctMud.resolveSpriteAsset(species, rows, [], DATA.spriteTemplates, window.tmctMud.SPRITE_REGISTRY);
554
+ }
555
+ return "";
556
+ }
557
+
558
+ function objectSvgFor(spriteClass, rows) {
559
+ if (window.tmctMud && window.tmctMud.resolveSpriteForClass) {
560
+ return window.tmctMud.resolveSpriteForClass(spriteClass, rows, window.tmctMud.SPRITE_REGISTRY);
561
+ }
562
+ return "";
563
+ }
564
+
565
+ function drawRoomBackdrop(canvas, kind) {
566
+ const ctx = canvas.getContext("2d");
567
+ const w = canvas.width, h = canvas.height;
568
+ ctx.clearRect(0, 0, w, h);
569
+ const grad = ctx.createLinearGradient(0, 0, 0, h);
570
+ if (kind === "outdoor") {
571
+ grad.addColorStop(0, "#9BB077"); grad.addColorStop(1, "#6B7A4F");
572
+ } else {
573
+ grad.addColorStop(0, "#5A4130"); grad.addColorStop(1, "#2B1D14");
574
+ }
575
+ ctx.fillStyle = grad;
576
+ ctx.fillRect(0, 0, w, h);
577
+ }
578
+
579
+ function renderRoomView(character, rows, state, here) {
580
+ const w = windowIdFor(character);
581
+ const canvas = el(w + "-canvas");
582
+ const kind = roomKindForRoom(rows, here);
583
+ drawRoomBackdrop(canvas, kind);
584
+
585
+ const layer = el(w + "-sprites");
586
+ layer.innerHTML = "";
587
+ const selfNode = document.createElement("div");
588
+ selfNode.className = "sprite self";
589
+ selfNode.innerHTML = spriteSvgFor(speciesOfCharacter(character), rows);
590
+ layer.appendChild(selfNode);
591
+
592
+ for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
593
+ const node = document.createElement("div");
594
+ const isCast = characterIds.indexOf(obj.subject) !== -1;
595
+ node.className = "sprite" + (isCast ? " roommate" : "");
596
+ node.innerHTML = isCast ? spriteSvgFor(speciesOfCharacter(obj.subject), rows) : objectSvgFor(obj.spriteClass, rows);
597
+ node.title = obj.subject;
598
+ layer.appendChild(node);
599
+ }
600
+
601
+ const bubbleEl = el(w + "-bubble");
602
+ const bubble = speechBubbles.get(here);
603
+ if (bubble && bubble.expiresAtTurn >= globalTurn) {
604
+ bubbleEl.textContent = bubble.text;
605
+ bubbleEl.hidden = false;
606
+ bubbleEl.classList.add("shown");
607
+ } else {
608
+ bubbleEl.classList.remove("shown");
609
+ bubbleEl.hidden = true;
610
+ }
611
+
612
+ const flourishEl = el(w + "-flourish");
613
+ if (freshlyDugRoom && freshlyDugRoom === here && !reduceMotion) {
614
+ flourishEl.hidden = false;
615
+ flourishEl.classList.add("shown");
616
+ setTimeout(function () { flourishEl.classList.remove("shown"); flourishEl.hidden = true; }, 900);
617
+ }
618
+
619
+ el(w + "-caption").textContent = roomCaptionFor(rows, state, here, character);
620
+ }
621
+
622
+ function roomCaptionFor(rows, state, here, character) {
623
+ if (!window.tmctMud) return here;
624
+ const view = window.tmctMud.worldDigestRows(rows, state, character);
625
+ const lines = view.filter(function (r) { return r.subject.toLowerCase() === here.toLowerCase(); })
626
+ .map(function (r) { return r.subject + " " + r.predicate + " " + r.object + "."; });
627
+ return lines.length ? lines.join(" ") : ("You are in the " + here + ".");
628
+ }
629
+
630
+ function renderPouch(character, rows, state) {
631
+ const w = windowIdFor(character);
632
+ const list = el(w + "-pouch-list");
633
+ const items = carriedItemsFor(rows, state, character);
634
+ list.innerHTML = items.map(function (i) { return "<li>" + esc(i.subject) + "</li>"; }).join("");
635
+ const mass = state.masses.get(character);
636
+ const cfgKey = speciesOfCharacter(character);
637
+ const drain = DATA.mudConfig[cfgKey + "MassDecrementPerTurn"];
638
+ el(w + "-stats").textContent = "mass " + (mass ? mass.value : "?") + (drain !== undefined ? " \\u00b7 drain/turn " + drain : "");
639
+ }
640
+
641
+ function renderMinimap(character, rows, state, here) {
642
+ const w = windowIdFor(character);
643
+ const mapEl = el(w + "-minimap");
644
+ const visited = session.windows[character].visitedRoomIds();
645
+ const levels = levelsOf(state, DATA.rootRoom);
646
+ const myLevel = levels.get(here);
647
+ const rooms = visited.filter(function (r) { return levels.get(r) === myLevel; }).sort();
648
+ mapEl.innerHTML = rooms.map(function (r) {
649
+ return "<span class=\\"minimap-room" + (r === here ? " current" : "") + "\\">" + esc(r) + "</span>";
650
+ }).join("");
651
+ }
652
+
653
+ function renderWorldMap(rows, state) {
654
+ const levels = levelsOf(state, DATA.rootRoom);
655
+ const bands = levelBands(levels);
656
+ el("worldMapBands").innerHTML = bands.map(function (band) {
657
+ const rooms = band.rooms.map(function (r) {
658
+ const cast = charactersInRoom(state, r, characterIds);
659
+ const castText = cast.length ? "<span class=\\"room-cast\\">" + cast.map(function (c) { return speciesOfCharacter(c); }).join(",") + "</span>" : "";
660
+ const fresh = r === freshlyDugRoom ? " freshly-dug" : "";
661
+ return "<span class=\\"map-room" + fresh + "\\">" + esc(r) + castText + "</span>";
662
+ }).join("");
663
+ return "<div class=\\"map-band\\"><span class=\\"band-level\\">L" + band.level + "</span><span class=\\"band-rooms\\">" + rooms + "</span></div>";
664
+ }).join("");
665
+ el("worldMapTurn").textContent = "turn " + globalTurn;
666
+ }
667
+
668
+ function renderAll() {
669
+ if (!session) return;
670
+ updateGlobalTurnLabel();
671
+ session.snapshot().then(function (snap) {
672
+ renderWorldMap(snap.rows, snap.state);
673
+ for (const c of characters) {
674
+ const here = snap.state.placements.get(c.id) ? snap.state.placements.get(c.id).object : null;
675
+ const w = "window-" + c.slot;
676
+ el(w + "-turn").textContent = "turn " + globalTurn;
677
+ el(w + "-count").textContent = globalTurn + " turns";
678
+ if (!here) continue;
679
+ renderRoomView(c.id, snap.rows, snap.state, here);
680
+ renderPouch(c.id, snap.rows, snap.state);
681
+ renderMinimap(c.id, snap.rows, snap.state, here);
682
+ }
683
+ freshlyDugRoom = null;
684
+ });
685
+ }
686
+
687
+ // ---- the rail -------------------------------------------------------------
688
+ function wireRail() {
689
+ const autoBtn = el("autoToggle");
690
+ const delaySlider = el("delaySlider");
691
+ const maxTurnsSlider = el("maxTurnsSlider");
692
+ const resetBtn = el("resetBtn");
693
+ autoBtn.addEventListener("click", function () {
694
+ autoOn = !autoOn;
695
+ autoBtn.setAttribute("aria-pressed", autoOn ? "true" : "false");
696
+ autoBtn.textContent = autoOn ? "\\u23F8 auto" : "\\u25B6 auto";
697
+ for (const c of characters) {
698
+ const ticker = tickers[c.id];
699
+ if (!ticker) continue;
700
+ if (autoOn) ticker.play(); else ticker.pause();
701
+ }
702
+ });
703
+ delaySlider.addEventListener("input", function () {
704
+ delayMs = Number(delaySlider.value);
705
+ el("delayValue").textContent = delayMs + "ms";
706
+ });
707
+ maxTurnsSlider.addEventListener("input", function () {
708
+ maxTurns = Number(maxTurnsSlider.value);
709
+ el("maxTurnsValue").textContent = String(maxTurns);
710
+ });
711
+ resetBtn.addEventListener("click", function () { boot(); });
712
+ }
713
+
714
+ function updateGlobalTurnLabel() {
715
+ el("globalTurnCount").textContent = "turns: " + globalTurn;
716
+ }
717
+
718
+ async function boot() {
719
+ for (const c of characters) {
720
+ const ticker = tickers[c.id];
721
+ if (ticker) ticker.pause();
722
+ }
723
+ globalTurn = 0;
724
+ knownRoomIds.clear();
725
+ freshlyDugRoom = null;
726
+ speechBubbles.clear();
727
+ tickChain = Promise.resolve();
728
+ for (const c of characters) {
729
+ const w = "window-" + c.slot;
730
+ el(w + "-chatlog").innerHTML = "";
731
+ }
732
+ session = await window.tmctMud.createMudSession(DATA.worldPayload, { characters: characterIds });
733
+ for (const c of characters) {
734
+ const w = "window-" + c.slot;
735
+ el(w + "-play").disabled = false;
736
+ el(w + "-step").disabled = false;
737
+ el(w + "-chatq").disabled = false;
738
+ for (const pill of el(w + "-chatpills").querySelectorAll(".pill")) pill.disabled = false;
739
+ ensureTicker(c.id);
740
+ wireChat(c.id);
741
+ }
742
+ updateGlobalTurnLabel();
743
+ renderAll();
744
+ // Default on load: only the NW window plays, the rest start paused.
745
+ const nw = characters.find(function (c) { return c.slot === "nw"; });
746
+ if (nw) tickers[nw.id].play();
747
+ }
748
+
749
+ wireRail();
750
+ boot();
751
+ })();`;
752
+ }