@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.7
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.
- package/README.md +6 -0
- package/bin/tmct.mjs +63 -2
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +8 -1
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +118 -23
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +228 -24
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +108 -25
- package/src/services/ledger-viz.mjs +4 -2
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +17 -0
- package/src/services/mudiii-scene.mjs +271 -24
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +302 -125
- package/src/services/plan-viz.mjs +23 -2
- package/src/services/predator-prey.mjs +82 -33
- package/src/services/research-viz.mjs +12 -19
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +10 -3
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- package/src/tools/server.mjs +31 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// mudiii-viz.mjs — mudiii.html: the one-player town-square demo
|
|
2
|
-
//
|
|
1
|
+
// mudiii-viz.mjs — mudiii.html: the one-player town-square demo, over a real
|
|
2
|
+
// three.js scene rather than mud.html's canvas-drawn
|
|
3
3
|
// room boxes. mud.html's whole control deck carries over unchanged (same
|
|
4
4
|
// ids, same ranges, same defaults — see MUDIII_STYLE and renderMudiiiHtml's
|
|
5
5
|
// own header below for the two sliders' new labels); what mudiii adds is a
|
|
@@ -23,27 +23,23 @@
|
|
|
23
23
|
// the map panel, the HUD, the deck or the chat down with it.
|
|
24
24
|
//
|
|
25
25
|
// Deliberately absent, all P2P (mud.html's #statePill, share/join buttons,
|
|
26
|
-
// the share overlay, the wave button
|
|
27
|
-
//
|
|
26
|
+
// the share overlay, the wave button): this is the 1-player page. A later
|
|
27
|
+
// document adds sharing back from the same bundle.
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// are spliced into the page script from viz-ticker.mjs, not carried by the
|
|
34
|
-
// browser entry.
|
|
29
|
+
// The page publishes through the ONE `globalThis.tmct` surface
|
|
30
|
+
// (tmct-surface.mjs), never a page-scoped bag of its own, and
|
|
31
|
+
// `createTicker`/`createSerialQueue` are spliced into the page script from
|
|
32
|
+
// viz-ticker.mjs rather than carried by the browser entry.
|
|
35
33
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// was written against. `runPredatorPreyTick(memoryDir, opts)` returns
|
|
34
|
+
// One ticker drives the whole world, not one per agent:
|
|
35
|
+
// `runPredatorPreyTick(memoryDir, opts)` returns
|
|
39
36
|
// `{ turn, agents, items, ecology }` for the WHOLE WORLD in one call — it has
|
|
40
37
|
// no per-character entry point the way mud-turn.mjs's `runMudTurn(character,
|
|
41
38
|
// ...)` does, so there is nothing for a second or third ticker to drive that
|
|
42
39
|
// the first one has not already advanced. This page runs ONE shared ticker
|
|
43
40
|
// for the whole simulation, serialized through the same createSerialQueue
|
|
44
|
-
// mud.html uses for its own multi-pane writes; every HUD card
|
|
45
|
-
//
|
|
46
|
-
// was actually asking for.
|
|
41
|
+
// mud.html uses for its own multi-pane writes; every HUD card reads its own
|
|
42
|
+
// agent's slice of the one tick result.
|
|
47
43
|
import {
|
|
48
44
|
THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
|
|
49
45
|
rowsForWorld, appendLogLine, wordBeforeCursor, demoEyebrowHtml, EYEBROW_LINKS_CSS,
|
|
@@ -54,6 +50,7 @@ import {
|
|
|
54
50
|
pillCandidates, matchPills, pillCompleteMarkup, createPillComplete, PILL_COMPLETE_CSS,
|
|
55
51
|
} from "./pill-complete.mjs";
|
|
56
52
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
53
|
+
import { DEFAULT_FACING, DEFAULT_GRID_SIZE } from "../domain/town-square-world.mjs";
|
|
57
54
|
import { believedFactSentence } from "./mudiii-turn.mjs";
|
|
58
55
|
|
|
59
56
|
// The scene module and this one import each other: this file embeds the
|
|
@@ -79,11 +76,10 @@ const NPC_COUNT_LABELLED = [1, 5, 10];
|
|
|
79
76
|
const DEFAULT_NPC_COUNT = 2;
|
|
80
77
|
const DEFAULT_DELAY_MS = 220;
|
|
81
78
|
const DEFAULT_MAX_TURNS = 400;
|
|
82
|
-
// test/fixtures/mudiii-ticks.json's own board size — the fallback for a
|
|
83
|
-
// scenario that names no gridSize of its own.
|
|
84
|
-
const DEFAULT_GRID_SIZE = 12;
|
|
85
|
-
const DEFAULT_FACING = "south";
|
|
86
79
|
const CAMERA_MODES = ["follow", "pov", "overhead"];
|
|
80
|
+
// Every export below except renderMudiiiHtml is spliced by `.toString()` into
|
|
81
|
+
// a generated script that shares no scope with this module, so none of them
|
|
82
|
+
// may read a binding declared up here. mudiii-viz.test.mjs holds the line.
|
|
87
83
|
// The ring reads ABSOLUTE, not relative to whichever way an agent happens to
|
|
88
84
|
// face: every other direction word on this page is a compass point (a told
|
|
89
85
|
// fact says "the goblin is east", the map is north-up), and driveRequest takes
|
|
@@ -221,6 +217,48 @@ export function occupiedCells(agents, items) {
|
|
|
221
217
|
return cells;
|
|
222
218
|
}
|
|
223
219
|
|
|
220
|
+
/** Who and what stands where right now, as `{ subject, cell }[]` sorted by
|
|
221
|
+
* subject — the edit panel's own read of a fact list that carries the whole
|
|
222
|
+
* tape rather than a board.
|
|
223
|
+
*
|
|
224
|
+
* The engine stamps each turn's placement row `<id>@turn<N>`, or
|
|
225
|
+
* `<id>@epoch<E>@turn<N>` once a reset has moved the epoch on, so a raw
|
|
226
|
+
* `mgx:currently-in` sweep returns one row per agent per turn played. This
|
|
227
|
+
* folds them to the latest row per base subject by `(epoch, turn)`, the same
|
|
228
|
+
* order the engine's own fold ranks by. A bare subject is the world's
|
|
229
|
+
* authored placement — every prop is one — and ranks below any stamped row
|
|
230
|
+
* for the same base rather than competing with it.
|
|
231
|
+
*
|
|
232
|
+
* Anything carrying `mgx:eaten-by` or `mgx:starved` is dropped: it stood
|
|
233
|
+
* somewhere once, and the panel is asked where things stand now. Pure,
|
|
234
|
+
* self-contained. */
|
|
235
|
+
export function currentPlacementsFrom(rows) {
|
|
236
|
+
const STAMP = /^(.*?)(?:@epoch(\d+))?@turn(\d+)$/;
|
|
237
|
+
const latest = new Map();
|
|
238
|
+
const gone = new Set();
|
|
239
|
+
for (const row of rows || []) {
|
|
240
|
+
if (!row || !row.subject) continue;
|
|
241
|
+
const match = STAMP.exec(String(row.subject));
|
|
242
|
+
const base = match ? match[1] : String(row.subject);
|
|
243
|
+
if (row.predicate === "mgx:eaten-by" || row.predicate === "mgx:starved") {
|
|
244
|
+
gone.add(base);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (row.predicate !== "mgx:currently-in") continue;
|
|
248
|
+
const epoch = match ? Number(match[2] || 0) : -1;
|
|
249
|
+
const turn = match ? Number(match[3]) : -1;
|
|
250
|
+
const prior = latest.get(base);
|
|
251
|
+
if (prior && (prior.epoch > epoch || (prior.epoch === epoch && prior.turn > turn))) continue;
|
|
252
|
+
latest.set(base, { epoch, turn, cell: row.object });
|
|
253
|
+
}
|
|
254
|
+
const placements = [];
|
|
255
|
+
for (const [subject, entry] of latest) {
|
|
256
|
+
if (!gone.has(subject)) placements.push({ subject, cell: entry.cell });
|
|
257
|
+
}
|
|
258
|
+
placements.sort((a, b) => a.subject.localeCompare(b.subject));
|
|
259
|
+
return placements;
|
|
260
|
+
}
|
|
261
|
+
|
|
224
262
|
/** Why a click-to-move or a food placement on `cell` would be refused, or
|
|
225
263
|
* null when it is clear — `"cell-4-3 is blocked"`, worded once so both the
|
|
226
264
|
* 3D raycast click and a typed "go there" chat verb read the same refusal.
|
|
@@ -246,16 +284,40 @@ export function blockedCellReason(cell, props, agents) {
|
|
|
246
284
|
* `cellToWorld` does.
|
|
247
285
|
*
|
|
248
286
|
* "overhead" ignores `agent` entirely and looks straight down at the
|
|
249
|
-
* board's own centre
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
|
|
287
|
+
* board's own centre, high enough that the whole board fits the canvas
|
|
288
|
+
* `view` describes (`{ aspect, fovDegrees }`, the live camera's own two
|
|
289
|
+
* numbers). "follow" sits back and above the agent's cell, offset opposite
|
|
290
|
+
* its facing, looking at the agent. "pov" sits AT the agent's cell at eye
|
|
291
|
+
* height, looking the way it is facing. Returns null when a non-overhead
|
|
292
|
+
* mode is asked for with no agent (or an agent standing on no cell) to rig
|
|
293
|
+
* against. Pure, self-contained — calls `cellToWorld` by bare name, spliced
|
|
294
|
+
* alongside it. */
|
|
295
|
+
export function cameraRigFor(mode, agent, gridSize, view) {
|
|
256
296
|
const cellSize = 1;
|
|
297
|
+
// Every fallback here is written out rather than read from this module's own
|
|
298
|
+
// constants: the browser runs a `.toString()` copy of this function in a
|
|
299
|
+
// script that declares none of them, and the drive ring's diagonals set
|
|
300
|
+
// exactly the intercardinal facing that reaches the facing one.
|
|
301
|
+
const FALLBACK_GRID_SIZE = 12;
|
|
257
302
|
if (mode === "overhead") {
|
|
258
|
-
const
|
|
303
|
+
const FALLBACK_FOV_DEGREES = 55;
|
|
304
|
+
// Looking straight down pushes the TOP of a standing model outward from
|
|
305
|
+
// the centre of frame, so a fit measured on the flat ground plane alone
|
|
306
|
+
// cuts the head off whoever stands in an outermost cell. This is the room
|
|
307
|
+
// the tallest of the cast needs out there, counting the id label floating
|
|
308
|
+
// above it, which is the part a visitor most needs to read. Props run
|
|
309
|
+
// taller still and are allowed to overhang.
|
|
310
|
+
const EDGE_ROOM = 1.2;
|
|
311
|
+
const board = Math.max(4, Number(gridSize) || FALLBACK_GRID_SIZE) * cellSize;
|
|
312
|
+
const fovDegrees = Number(view && view.fovDegrees) > 0 ? Number(view.fovDegrees) : FALLBACK_FOV_DEGREES;
|
|
313
|
+
const aspect = Number(view && view.aspect) > 0 ? Number(view.aspect) : 1;
|
|
314
|
+
const tanHalfVertical = Math.tan((Math.min(fovDegrees, 175) * Math.PI) / 360);
|
|
315
|
+
const tanHalfHorizontal = tanHalfVertical * aspect;
|
|
316
|
+
// Fit the TIGHTER axis. A wide canvas runs out of height first, a tall one
|
|
317
|
+
// runs out of width, and taking the larger of the two distances is what
|
|
318
|
+
// stops a portrait window cropping the board's own left and right edges.
|
|
319
|
+
const fitsBothAxes = Math.max(1 / tanHalfVertical, 1 / tanHalfHorizontal);
|
|
320
|
+
const height = (board / 2) * EDGE_ROOM * fitsBothAxes;
|
|
259
321
|
return { mode: "overhead", position: { x: 0, y: height, z: 0 }, lookAt: { x: 0, y: 0, z: 0 } };
|
|
260
322
|
}
|
|
261
323
|
if (!agent || !agent.cell) return null;
|
|
@@ -264,7 +326,7 @@ export function cameraRigFor(mode, agent, gridSize) {
|
|
|
264
326
|
const FACING_VECTOR = {
|
|
265
327
|
north: { x: 0, z: -1 }, south: { x: 0, z: 1 }, east: { x: 1, z: 0 }, west: { x: -1, z: 0 },
|
|
266
328
|
};
|
|
267
|
-
const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR
|
|
329
|
+
const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR.south;
|
|
268
330
|
if (mode === "pov") {
|
|
269
331
|
return {
|
|
270
332
|
mode: "pov",
|
|
@@ -320,6 +382,28 @@ export function nextCameraSelection(prev, agents, ecology) {
|
|
|
320
382
|
};
|
|
321
383
|
}
|
|
322
384
|
|
|
385
|
+
/** What one press on a camera-mode button should do, as
|
|
386
|
+
* `{ mode, selectedId, status }`.
|
|
387
|
+
*
|
|
388
|
+
* `overhead` needs nobody and keeps whatever was selected, so switching back
|
|
389
|
+
* to `follow` resumes the same agent. The other two need one, and after a
|
|
390
|
+
* fallback there is none: the agent that was followed has been eaten and the
|
|
391
|
+
* selection was cleared. The dropdown, meanwhile, still lists the survivors
|
|
392
|
+
* and shows its first one, so a press that did nothing left the deck naming
|
|
393
|
+
* an agent the camera was not on. `offered` is that dropdown value, and a
|
|
394
|
+
* press adopts it when it is still live.
|
|
395
|
+
*
|
|
396
|
+
* With nobody left to adopt the press stays overhead and says why, rather
|
|
397
|
+
* than lighting a button over a camera that did not move. `liveIds` is this
|
|
398
|
+
* turn's roster. Pure, self-contained. */
|
|
399
|
+
export function cameraSelectionForMode(mode, selectedId, offered, liveIds) {
|
|
400
|
+
const live = liveIds || [];
|
|
401
|
+
if (mode === "overhead") return { mode: "overhead", selectedId: selectedId || null, status: null };
|
|
402
|
+
if (selectedId && live.indexOf(selectedId) !== -1) return { mode, selectedId, status: null };
|
|
403
|
+
if (offered && live.indexOf(offered) !== -1) return { mode, selectedId: offered, status: null };
|
|
404
|
+
return { mode: "overhead", selectedId: null, status: "nobody left to follow — staying overhead." };
|
|
405
|
+
}
|
|
406
|
+
|
|
323
407
|
/** Every agent and item's own dot for the 2D top-down map panel, as
|
|
324
408
|
* percentage coordinates within a square panel (`{ id, kind, xPct, yPct
|
|
325
409
|
* }[]`) — never pixel values, so the panel's own CSS controls its actual
|
|
@@ -530,6 +614,8 @@ ${PILL_COMPLETE_CSS}
|
|
|
530
614
|
<section class="deck" aria-label="simulation controls">
|
|
531
615
|
<div class="deck-controls">
|
|
532
616
|
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
617
|
+
<button type="button" id="stepBtn">step</button>
|
|
618
|
+
<span class="deck-hint" id="stepHint" hidden>pause to step</span>
|
|
533
619
|
<button type="button" id="resetBtn">reset</button>
|
|
534
620
|
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which town square to play">
|
|
535
621
|
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
@@ -539,32 +625,55 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
|
|
|
539
625
|
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
540
626
|
</div>
|
|
541
627
|
<div class="deck-body">
|
|
542
|
-
<div class="deck-
|
|
543
|
-
<
|
|
544
|
-
<
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
<
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
<
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
628
|
+
<div class="deck-panels">
|
|
629
|
+
<div class="deck-stack deck-stack-1">
|
|
630
|
+
<label class="deck-slider">foxes
|
|
631
|
+
<input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
|
|
632
|
+
value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
|
|
633
|
+
list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
|
|
634
|
+
<datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
|
|
635
|
+
<span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
|
|
636
|
+
</label>
|
|
637
|
+
<label class="deck-slider">goblins
|
|
638
|
+
<input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
|
|
639
|
+
value="${DEFAULT_NPC_COUNT}"
|
|
640
|
+
list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
|
|
641
|
+
<datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
|
|
642
|
+
const n = NPC_COUNT_MIN + i;
|
|
643
|
+
return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
|
|
644
|
+
}).join("")}</datalist>
|
|
645
|
+
<span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
|
|
646
|
+
</label>
|
|
647
|
+
<label class="deck-slider">follow
|
|
648
|
+
<select id="agentSelect" class="deck-select" aria-label="which agent to follow"
|
|
649
|
+
aria-describedby="agentSelectHint">
|
|
650
|
+
${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
|
|
651
|
+
</select>
|
|
652
|
+
</label>
|
|
653
|
+
<span class="deck-hint" id="agentSelectHint" hidden>pause to swap</span>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="deck-stack deck-stack-2">
|
|
656
|
+
<label class="deck-slider">delay
|
|
657
|
+
<input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
|
|
658
|
+
<span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
|
|
659
|
+
</label>
|
|
660
|
+
<label class="deck-slider">max turns
|
|
661
|
+
<input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
|
|
662
|
+
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
663
|
+
</label>
|
|
664
|
+
<div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
|
|
665
|
+
<button type="button" data-mode="follow" aria-pressed="true">follow</button>
|
|
666
|
+
<button type="button" data-mode="pov" aria-pressed="false">pov</button>
|
|
667
|
+
<button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="deck-stack deck-stack-3">
|
|
671
|
+
<button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
|
|
672
|
+
<label class="deck-teach" title="With this on, a sentence like "The fox is at cell-3-4." writes a fact into the square instead of running as a command.">
|
|
673
|
+
<input type="checkbox" id="teachToggle">
|
|
674
|
+
teach
|
|
675
|
+
</label>
|
|
676
|
+
</div>
|
|
568
677
|
</div>
|
|
569
678
|
<section class="map-panel" id="mapPanel" aria-label="the town square, from above">
|
|
570
679
|
<div class="map-panel-head">
|
|
@@ -580,25 +689,6 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
|
|
|
580
689
|
</div>
|
|
581
690
|
</section>
|
|
582
691
|
</div>
|
|
583
|
-
<div class="deck-camera">
|
|
584
|
-
<label class="deck-slider">follow
|
|
585
|
-
<select id="agentSelect" class="deck-select" aria-label="which agent to follow"
|
|
586
|
-
aria-describedby="agentSelectHint">
|
|
587
|
-
${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
|
|
588
|
-
</select>
|
|
589
|
-
</label>
|
|
590
|
-
<span class="deck-hint" id="agentSelectHint" hidden>pause to swap</span>
|
|
591
|
-
<div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
|
|
592
|
-
<button type="button" data-mode="follow" aria-pressed="true">follow</button>
|
|
593
|
-
<button type="button" data-mode="pov" aria-pressed="false">pov</button>
|
|
594
|
-
<button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
|
|
595
|
-
</div>
|
|
596
|
-
<button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
|
|
597
|
-
<label class="deck-teach" title="With this on, a sentence like "The fox is at cell-3-4." writes a fact into the square instead of running as a command.">
|
|
598
|
-
<input type="checkbox" id="teachToggle">
|
|
599
|
-
teach
|
|
600
|
-
</label>
|
|
601
|
-
</div>
|
|
602
692
|
<div class="deck-info-popup mudiii-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
603
693
|
${MUDIII_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
604
694
|
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
@@ -694,7 +784,7 @@ const MUDIII_STYLE = `
|
|
|
694
784
|
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
695
785
|
padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
|
|
696
786
|
}
|
|
697
|
-
.deck-controls
|
|
787
|
+
.deck-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
698
788
|
.deck-info-btn {
|
|
699
789
|
font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
|
|
700
790
|
border-radius: 50%; border: 1px solid var(--square-stone-dark); background: rgba(255,255,255,.5);
|
|
@@ -725,12 +815,19 @@ const MUDIII_STYLE = `
|
|
|
725
815
|
.deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
|
|
726
816
|
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--square-stone-dark); background: var(--square-stone-dark); background: rgba(43,35,24,.9); color: var(--square-accent); border-radius: 2px; padding: .1rem .5rem; }
|
|
727
817
|
.deck-body { display: flex; gap: .7rem; align-items: flex-start; }
|
|
728
|
-
.deck-
|
|
818
|
+
.deck-panels { display: flex; flex-wrap: wrap; gap: .6rem 1rem; flex: 1 1 auto; min-width: 0; }
|
|
819
|
+
/* On a narrow viewport a stack is just a grouping label, not a real box:
|
|
820
|
+
its controls flow straight into .deck-panels' own wrap, one per row
|
|
821
|
+
beside the map. Wider breakpoints turn it back into a real column. */
|
|
822
|
+
.deck-stack { display: contents; }
|
|
729
823
|
.deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); min-width: 0; }
|
|
730
824
|
.deck-slider input[type="range"] { accent-color: var(--square-accent); flex: 1 1 4rem; min-width: 2.5rem; width: auto; max-width: 8rem; }
|
|
731
825
|
.deck-teach { display: flex; align-items: center; gap: .3rem; font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--square-stone-dark); cursor: pointer; }
|
|
732
826
|
.deck-teach input[type="checkbox"] { accent-color: var(--square-accent); }
|
|
733
|
-
|
|
827
|
+
/* A narrow stack column can be too tight for all three button labels on
|
|
828
|
+
one line, and a nowrap row would rather overflow into the next column
|
|
829
|
+
than shrink — wrap keeps it inside its own box. */
|
|
830
|
+
.camera-mode { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
|
|
734
831
|
.camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
|
|
735
832
|
.deck-info-popup {
|
|
736
833
|
position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
|
|
@@ -908,37 +1005,53 @@ const MUDIII_STYLE = `
|
|
|
908
1005
|
#editorText { min-height: 16rem; }
|
|
909
1006
|
}
|
|
910
1007
|
|
|
911
|
-
/* A landscape phone and a narrow desktop window are both under 900px but
|
|
912
|
-
want different slider/map arrangements, so the split has to key off
|
|
913
|
-
orientation as well as width. */
|
|
914
|
-
@media (max-width: 900px) and (orientation: landscape) {
|
|
915
|
-
.deck-sliders { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem 1rem; }
|
|
916
|
-
.deck-body { align-items: stretch; }
|
|
917
|
-
.map-panel { flex-basis: 33%; max-width: 33%; }
|
|
918
|
-
/* The square aspect-ratio that suits a tall portrait column would blow
|
|
919
|
-
the map back up to full column width in a short landscape viewport —
|
|
920
|
-
here it follows the two-row slider stack's own height instead. */
|
|
921
|
-
.map-panel-board { aspect-ratio: auto; min-height: 90px; }
|
|
922
|
-
}
|
|
923
|
-
|
|
924
1008
|
/* Half the deck is right on a phone and absurd on a 2000px window: a
|
|
925
1009
|
percentage has no ceiling, so a square board grew to roughly 950px tall
|
|
926
1010
|
and pushed the 3D view off the screen. Wide viewports get an absolute
|
|
927
1011
|
size instead, and keep the square — there is room for it here.
|
|
928
1012
|
|
|
929
|
-
The map is also taller than
|
|
930
|
-
empty stripe of parchment under them. On a
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
.deck-
|
|
934
|
-
the deck itself.
|
|
1013
|
+
The map is also taller than a single row of controls, which used to
|
|
1014
|
+
leave a tall empty stripe of parchment under them. On a landscape phone
|
|
1015
|
+
and a desktop window alike, the deck becomes one grid with three control
|
|
1016
|
+
columns beside the map. display:contents lifts .deck-panels' three
|
|
1017
|
+
.deck-stack children, and .map-panel, out of .deck-body so all four are
|
|
1018
|
+
grid items of the deck itself. A stack sits at its own natural height,
|
|
1019
|
+
top-aligned, rather than stretching its items apart to match the map —
|
|
1020
|
+
stretching them just traded the empty stripe for gaps between controls
|
|
1021
|
+
that belong together, and a hint drifting away from the select it
|
|
1022
|
+
describes. A shorter stack beside a taller map is fine; the leftover
|
|
1023
|
+
space stays honest whitespace instead. */
|
|
1024
|
+
@media (min-width: 901px), (max-width: 900px) and (orientation: landscape) {
|
|
1025
|
+
.deck-body, .deck-panels { display: contents; }
|
|
1026
|
+
.deck-stack {
|
|
1027
|
+
display: flex; flex-direction: column; gap: .5rem;
|
|
1028
|
+
grid-row: 2; align-self: start;
|
|
1029
|
+
}
|
|
1030
|
+
.deck-stack-1 { grid-column: 1; }
|
|
1031
|
+
.deck-stack-2 { grid-column: 2; }
|
|
1032
|
+
.deck-stack-3 { grid-column: 3; }
|
|
1033
|
+
/* Stretch is the flex column's default cross-axis behaviour, and the
|
|
1034
|
+
food pill is the one control here with a visible border — without
|
|
1035
|
+
this it grows to the stack's full width as a wide dashed box. */
|
|
1036
|
+
.deck-stack .pill.affordance { align-self: flex-start; }
|
|
1037
|
+
.map-panel { grid-column: 4; grid-row: 2; flex: 0 0 auto; max-width: none; }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
935
1040
|
@media (min-width: 901px) {
|
|
936
|
-
.deck { display: grid; grid-template-columns: minmax(0, 1fr) 240px; grid-template-rows: auto
|
|
1041
|
+
.deck { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 240px; grid-template-rows: auto auto; column-gap: .8rem; }
|
|
937
1042
|
.deck-controls { grid-column: 1 / -1; grid-row: 1; }
|
|
938
|
-
.
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1043
|
+
.map-panel { align-self: start; }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/* A landscape phone is under 900px but wants the same four-column
|
|
1047
|
+
treatment as desktop — just narrower columns and a map that stretches
|
|
1048
|
+
to the stacks' height rather than holding a fixed square, the same
|
|
1049
|
+
trade this breakpoint always made before the grid switch. */
|
|
1050
|
+
@media (max-width: 900px) and (orientation: landscape) {
|
|
1051
|
+
.deck { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(140px, 220px); grid-template-rows: auto auto; column-gap: .6rem; }
|
|
1052
|
+
.deck-controls { grid-column: 1 / -1; grid-row: 1; }
|
|
1053
|
+
.map-panel { align-self: stretch; }
|
|
1054
|
+
.map-panel-board { aspect-ratio: auto; min-height: 90px; }
|
|
942
1055
|
}
|
|
943
1056
|
|
|
944
1057
|
`;
|
|
@@ -972,9 +1085,11 @@ function pageScript() {
|
|
|
972
1085
|
const cellFromGroundPoint = ${cellFromGroundPoint.toString()};
|
|
973
1086
|
const propPlacementsFrom = ${propPlacementsFrom.toString()};
|
|
974
1087
|
const occupiedCells = ${occupiedCells.toString()};
|
|
1088
|
+
const currentPlacementsFrom = ${currentPlacementsFrom.toString()};
|
|
975
1089
|
const blockedCellReason = ${blockedCellReason.toString()};
|
|
976
1090
|
const cameraRigFor = ${cameraRigFor.toString()};
|
|
977
1091
|
const nextCameraSelection = ${nextCameraSelection.toString()};
|
|
1092
|
+
const cameraSelectionForMode = ${cameraSelectionForMode.toString()};
|
|
978
1093
|
const mapDotsFor = ${mapDotsFor.toString()};
|
|
979
1094
|
const mapBlocksFor = ${mapBlocksFor.toString()};
|
|
980
1095
|
const hudCardFieldsFor = ${hudCardFieldsFor.toString()};
|
|
@@ -1022,6 +1137,14 @@ function pageScript() {
|
|
|
1022
1137
|
// another agent. Without it, choosing someone new after a fox ate your
|
|
1023
1138
|
// goblin leaves the camera overhead and the follow button unlit.
|
|
1024
1139
|
let cameraModeBeforeFallback = null;
|
|
1140
|
+
// The cut to overhead the scene has been told to make NEXT tick. The page's
|
|
1141
|
+
// own camera state moves the moment the followed agent leaves the board,
|
|
1142
|
+
// because the deck must never offer an agent that is gone; the 3D camera
|
|
1143
|
+
// holds one more turn so the visitor watches the kill from the animal they
|
|
1144
|
+
// were riding instead of cutting away from it. A rig with nobody to aim at
|
|
1145
|
+
// freezes rather than drifting, which is what makes the held turn read as a
|
|
1146
|
+
// held shot.
|
|
1147
|
+
let deferredSceneCamera = null;
|
|
1025
1148
|
let foodArmed = false;
|
|
1026
1149
|
let livePills = [];
|
|
1027
1150
|
let selectedAddresseeId = null;
|
|
@@ -1062,6 +1185,13 @@ function pageScript() {
|
|
|
1062
1185
|
}
|
|
1063
1186
|
}
|
|
1064
1187
|
|
|
1188
|
+
// Every camera change the visitor asks for goes through here, so a pending
|
|
1189
|
+
// one-turn-late cut can never land on top of a mode they picked in between.
|
|
1190
|
+
function sendCameraToScene(state) {
|
|
1191
|
+
deferredSceneCamera = null;
|
|
1192
|
+
callScene("setCamera", state);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1065
1195
|
function applyTickResult(result) {
|
|
1066
1196
|
if (!result) return;
|
|
1067
1197
|
// The engine owns the count. Anything that advances a turn — the deck, a
|
|
@@ -1075,7 +1205,16 @@ function pageScript() {
|
|
|
1075
1205
|
const nextCamera = nextCameraSelection(camera, agentsList(), result.ecology || []);
|
|
1076
1206
|
if (nextCamera.status && camera.mode !== "overhead") cameraModeBeforeFallback = camera.mode;
|
|
1077
1207
|
camera = nextCamera;
|
|
1078
|
-
|
|
1208
|
+
if (deferredSceneCamera) {
|
|
1209
|
+
const cut = deferredSceneCamera;
|
|
1210
|
+
deferredSceneCamera = null;
|
|
1211
|
+
callScene("setCamera", cut);
|
|
1212
|
+
} else if (nextCamera.status) {
|
|
1213
|
+
// The status line names the kill NOW; the wide shot lands next turn.
|
|
1214
|
+
deferredSceneCamera = { mode: camera.mode, selectedId: camera.selectedId };
|
|
1215
|
+
} else {
|
|
1216
|
+
callScene("setCamera", camera);
|
|
1217
|
+
}
|
|
1079
1218
|
if (camera.status) setSceneStatus(camera.status);
|
|
1080
1219
|
}
|
|
1081
1220
|
|
|
@@ -1103,6 +1242,10 @@ function pageScript() {
|
|
|
1103
1242
|
// on top of the open dropdown and loses the pick.
|
|
1104
1243
|
el("agentSelect").disabled = state.playing;
|
|
1105
1244
|
el("agentSelectHint").hidden = !state.playing;
|
|
1245
|
+
// Step reads the same ticker state for the same reason: a hand-driven
|
|
1246
|
+
// turn while the board plays itself lands in the middle of one.
|
|
1247
|
+
el("stepBtn").disabled = state.playing || state.animating;
|
|
1248
|
+
el("stepHint").hidden = !state.playing;
|
|
1106
1249
|
},
|
|
1107
1250
|
hasNext: hasNext,
|
|
1108
1251
|
wait: liveWait,
|
|
@@ -1432,7 +1575,7 @@ function pageScript() {
|
|
|
1432
1575
|
camera = { mode: mode, selectedId: id, status: null };
|
|
1433
1576
|
renderCameraButtons();
|
|
1434
1577
|
renderDriveRing();
|
|
1435
|
-
|
|
1578
|
+
sendCameraToScene(camera);
|
|
1436
1579
|
});
|
|
1437
1580
|
|
|
1438
1581
|
function renderCameraButtons() {
|
|
@@ -1445,9 +1588,14 @@ function pageScript() {
|
|
|
1445
1588
|
const btn = e.target.closest("button[data-mode]");
|
|
1446
1589
|
if (!btn) return;
|
|
1447
1590
|
cameraModeBeforeFallback = null;
|
|
1448
|
-
camera =
|
|
1591
|
+
camera = cameraSelectionForMode(
|
|
1592
|
+
btn.getAttribute("data-mode"), camera.selectedId, el("agentSelect").value, Object.keys(agentsById),
|
|
1593
|
+
);
|
|
1449
1594
|
renderCameraButtons();
|
|
1450
|
-
|
|
1595
|
+
renderAgentSelect();
|
|
1596
|
+
renderDriveRing();
|
|
1597
|
+
setSceneStatus(camera.status || "");
|
|
1598
|
+
sendCameraToScene(camera);
|
|
1451
1599
|
});
|
|
1452
1600
|
|
|
1453
1601
|
// ---- the control deck ----------------------------------------------------
|
|
@@ -1480,6 +1628,13 @@ function pageScript() {
|
|
|
1480
1628
|
el("playerCountSlider").addEventListener("change", function () { boot(); });
|
|
1481
1629
|
el("npcCountSlider").addEventListener("input", function () { showGoblinCount(chosenGoblinCount()); });
|
|
1482
1630
|
el("npcCountSlider").addEventListener("change", function () { boot(); });
|
|
1631
|
+
// One whole turn: every agent decides and moves, the ecology pass runs,
|
|
1632
|
+
// and the turn counter reads exactly one higher. It goes through the same
|
|
1633
|
+
// ticker play() uses, so the two can never run a turn on top of each other.
|
|
1634
|
+
el("stepBtn").addEventListener("click", function () {
|
|
1635
|
+
if (!session || autoOn) return;
|
|
1636
|
+
ensureTicker().stepOnce();
|
|
1637
|
+
});
|
|
1483
1638
|
el("resetBtn").addEventListener("click", function () { resetBoard(); });
|
|
1484
1639
|
const scenarioSelect = el("scenarioSelect");
|
|
1485
1640
|
if (scenarioSelect) {
|
|
@@ -1520,15 +1675,10 @@ function pageScript() {
|
|
|
1520
1675
|
let allStoreRows = [];
|
|
1521
1676
|
|
|
1522
1677
|
function renderEditPlacements() {
|
|
1523
|
-
const placements =
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
}
|
|
1528
|
-
const subjects = Object.keys(placements).sort();
|
|
1529
|
-
el("editPlacements").innerHTML = subjects.length
|
|
1530
|
-
? subjects.map(function (s) {
|
|
1531
|
-
return '<div class="edit-placement-row"><span class="mono">' + esc(s) + '</span><span>' + esc(placements[s]) + "</span></div>";
|
|
1678
|
+
const placements = currentPlacementsFrom(editRows);
|
|
1679
|
+
el("editPlacements").innerHTML = placements.length
|
|
1680
|
+
? placements.map(function (p) {
|
|
1681
|
+
return '<div class="edit-placement-row"><span class="mono">' + esc(p.subject) + '</span><span>' + esc(p.cell) + "</span></div>";
|
|
1532
1682
|
}).join("")
|
|
1533
1683
|
: '<span class="edit-empty">nothing placed yet</span>';
|
|
1534
1684
|
}
|
|
@@ -1623,7 +1773,7 @@ function pageScript() {
|
|
|
1623
1773
|
await callScene("boot", {
|
|
1624
1774
|
propPlacements: props, assetManifest: DATA.assetManifest, gridSize: gridSizeOf(), cellSize: 1,
|
|
1625
1775
|
});
|
|
1626
|
-
|
|
1776
|
+
sendCameraToScene(camera);
|
|
1627
1777
|
callScene("applyTick", { agents: agentsById, items: itemsById, ecology: [] });
|
|
1628
1778
|
}
|
|
1629
1779
|
|
|
@@ -1656,6 +1806,32 @@ function pageScript() {
|
|
|
1656
1806
|
// reduced motion gets the opening board drawn and left still — the play
|
|
1657
1807
|
// control is right there — because an autoplaying board is exactly the
|
|
1658
1808
|
// unasked-for movement that setting is about.
|
|
1809
|
+
|
|
1810
|
+
// The camera opens riding a goblin. The goblins are the prey, so the first
|
|
1811
|
+
// thing a visitor sees is a chase from inside the animal being chased; when
|
|
1812
|
+
// it is caught the camera cuts wide and the rest of the round plays out
|
|
1813
|
+
// overhead. Falls back to whoever is first by id on a square with no prey.
|
|
1814
|
+
function openingFollowId(agents) {
|
|
1815
|
+
const ids = Object.keys(agents || {}).sort();
|
|
1816
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
1817
|
+
if (agents[ids[i]] && agents[ids[i]].role === "prey") return ids[i];
|
|
1818
|
+
}
|
|
1819
|
+
return ids[0] || null;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// The play control put back to a stopped board. The ticker draws this
|
|
1823
|
+
// itself while it lives, but a Reset throws the ticker away, so the button
|
|
1824
|
+
// would keep reading "pause" over a board that is standing still.
|
|
1825
|
+
function showStopped() {
|
|
1826
|
+
const playBtn = el("autoToggle");
|
|
1827
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
1828
|
+
playBtn.textContent = "\\u25B6 play";
|
|
1829
|
+
el("agentSelect").disabled = false;
|
|
1830
|
+
el("agentSelectHint").hidden = true;
|
|
1831
|
+
el("stepBtn").disabled = false;
|
|
1832
|
+
el("stepHint").hidden = true;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1659
1835
|
let bootSeq = 0;
|
|
1660
1836
|
async function boot() {
|
|
1661
1837
|
const seq = bootSeq += 1;
|
|
@@ -1665,6 +1841,7 @@ function pageScript() {
|
|
|
1665
1841
|
tickQueue = createSerialQueue();
|
|
1666
1842
|
camera = { mode: "follow", selectedId: null, status: null };
|
|
1667
1843
|
cameraModeBeforeFallback = null;
|
|
1844
|
+
deferredSceneCamera = null;
|
|
1668
1845
|
expandedAgents.clear();
|
|
1669
1846
|
const s = scenario();
|
|
1670
1847
|
const foxes = mintRoster(rosterPrefixFor(s, "predator"), chosenFoxCount());
|
|
@@ -1692,7 +1869,7 @@ function pageScript() {
|
|
|
1692
1869
|
// the cells both come back from it rather than being guessed here.
|
|
1693
1870
|
const opening = await session.board();
|
|
1694
1871
|
if (seq !== bootSeq) return;
|
|
1695
|
-
camera.selectedId =
|
|
1872
|
+
camera.selectedId = openingFollowId(opening.agents);
|
|
1696
1873
|
applyTickResult(opening);
|
|
1697
1874
|
renderAll();
|
|
1698
1875
|
if (!prefersReducedMotion()) {
|
|
@@ -1705,9 +1882,11 @@ function pageScript() {
|
|
|
1705
1882
|
// the world's facts, everything taught into it and every editor change all
|
|
1706
1883
|
// stand, and only the animals are minted again. Re-opening would throw the
|
|
1707
1884
|
// taught facts away with the cast, which is not what "reset the board" says.
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1885
|
+
//
|
|
1886
|
+
// A Reset leaves the board STOPPED, whatever it was doing before. Opening
|
|
1887
|
+
// the page is the one time the square starts itself; after that the visitor
|
|
1888
|
+
// says when it runs. The shared ticker in viz-ticker.mjs already resets this
|
|
1889
|
+
// way, and this page keeps its own play flag, so it says so here too.
|
|
1711
1890
|
async function resetBoard() {
|
|
1712
1891
|
if (!session) return boot();
|
|
1713
1892
|
autoOn = false;
|
|
@@ -1720,15 +1899,13 @@ function pageScript() {
|
|
|
1720
1899
|
showFoxCount(foxes.length);
|
|
1721
1900
|
showGoblinCount(goblins.length);
|
|
1722
1901
|
const board = await serializeTick(function () { return session.recast({ agents: cast }); });
|
|
1723
|
-
camera = { mode: "follow", selectedId:
|
|
1902
|
+
camera = { mode: "follow", selectedId: openingFollowId(board.agents), status: null };
|
|
1724
1903
|
cameraModeBeforeFallback = null;
|
|
1904
|
+
deferredSceneCamera = null;
|
|
1725
1905
|
applyTickResult(board);
|
|
1726
1906
|
renderAll();
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
autoOn = true;
|
|
1730
|
-
ensureTicker().play();
|
|
1731
|
-
}
|
|
1907
|
+
showStopped();
|
|
1908
|
+
setSceneStatus("re-cast and stopped \\u2014 the square's own facts stand, and everything taught into it. Press play.");
|
|
1732
1909
|
}
|
|
1733
1910
|
|
|
1734
1911
|
function renderAll() {
|