@polycode-projects/the-mechanical-code-talker 5.0.0 → 5.0.2
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/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +35 -5
- package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
- package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
- package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
- package/corpus/worlds/src/town-square-chapel.jsonl +929 -0
- package/corpus/worlds/src/town-square-market.jsonl +455 -0
- package/corpus/worlds/src/town-square.jsonl +687 -0
- package/package.json +5 -3
- package/src/domain/inflect.mjs +67 -0
- package/src/domain/memory/compaction.mjs +2 -0
- package/src/domain/town-square-world.mjs +416 -0
- package/src/services/chat.mjs +24 -0
- package/src/services/mud-editor.mjs +27 -0
- package/src/services/mudiii-scene.mjs +766 -0
- package/src/services/mudiii-turn.mjs +670 -0
- package/src/services/mudiii-viz.mjs +1247 -0
- package/src/services/predator-prey.mjs +916 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +127 -127
- package/src/surfaces/web/mudiii-browser-entry.mjs +224 -0
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
// mudiii-viz.mjs — mudiii.html: the one-player town-square demo PLAN_MUD_MUDIII.md
|
|
2
|
+
// describes, over a real three.js scene rather than mud.html's canvas-drawn
|
|
3
|
+
// room boxes. mud.html's whole control deck carries over unchanged (same
|
|
4
|
+
// ids, same ranges, same defaults — see MUDIII_STYLE and renderMudiiiHtml's
|
|
5
|
+
// own header below for the two sliders' new labels); what mudiii adds is a
|
|
6
|
+
// free camera (follow/pov/overhead), a single shared conversation instead of
|
|
7
|
+
// one pane per player, and an omniscient top-down map panel standing in for
|
|
8
|
+
// mud.html's burrow survey.
|
|
9
|
+
//
|
|
10
|
+
// This module owns the PAGE SHELL only. The 3D scene itself — the actual
|
|
11
|
+
// three.js renderer, the model loader, the per-frame camera update — is a
|
|
12
|
+
// concurrent track's file, src/services/mudiii-scene.mjs, reached through
|
|
13
|
+
// exactly one frozen function: `mudiiiSceneScript(opts) -> string`, a
|
|
14
|
+
// standalone inline <script> this page embeds next to its own. That module
|
|
15
|
+
// does not exist in every worktree yet (see the guarded import below), so
|
|
16
|
+
// this file also ships a "" stub until it lands — no edit needed here when
|
|
17
|
+
// it does.
|
|
18
|
+
//
|
|
19
|
+
// The contract runs both ways. Scene -> shell: the scene script calls
|
|
20
|
+
// `window.mudiiiHandleSceneClick(cellId)` on a raycast hit. Shell -> scene:
|
|
21
|
+
// this page's own script calls `window.mudiiiScene.boot(...)` once per
|
|
22
|
+
// world/reset/scenario switch, `.applyTick(...)` every tick, and
|
|
23
|
+
// `.setCamera(...)` on every camera-mode change, agent-select change and
|
|
24
|
+
// `nextCameraSelection` fallback (see `callScene`, below) — every call
|
|
25
|
+
// guarded, because a failed three.js vendor load (~800 KB) must never take
|
|
26
|
+
// the map panel, the HUD, the deck or the chat down with it.
|
|
27
|
+
//
|
|
28
|
+
// Deliberately absent, all P2P (mud.html's #statePill, share/join buttons,
|
|
29
|
+
// the share overlay, the wave button, the compass ring): this is the
|
|
30
|
+
// 1-player page. A later document adds sharing back from the same bundle.
|
|
31
|
+
//
|
|
32
|
+
// Two corrections applied against PLAN_MUD_MUDIII.md's own text (see
|
|
33
|
+
// AGENTS.md/the dispatch brief this track was built from): the page
|
|
34
|
+
// publishes through the ONE `globalThis.tmct` surface (tmct-surface.mjs),
|
|
35
|
+
// never a page-scoped `tmctMudiii` bag; and `createTicker`/`createSerialQueue`
|
|
36
|
+
// are spliced into the page script from viz-ticker.mjs, not carried by the
|
|
37
|
+
// browser entry.
|
|
38
|
+
//
|
|
39
|
+
// A THIRD correction, found while wiring the deck's own play control: the
|
|
40
|
+
// brief's "one createTicker per agent" does not fit the engine contract it
|
|
41
|
+
// was written against. `runPredatorPreyTick(memoryDir, opts)` returns
|
|
42
|
+
// `{ turn, agents, items, ecology }` for the WHOLE WORLD in one call — it has
|
|
43
|
+
// no per-character entry point the way mud-turn.mjs's `runMudTurn(character,
|
|
44
|
+
// ...)` does, so there is nothing for a second or third ticker to drive that
|
|
45
|
+
// the first one has not already advanced. This page runs ONE shared ticker
|
|
46
|
+
// for the whole simulation, serialized through the same createSerialQueue
|
|
47
|
+
// mud.html uses for its own multi-pane writes; every HUD card still reads
|
|
48
|
+
// its own agent's slice of the one tick result, which is what "per agent"
|
|
49
|
+
// was actually asking for.
|
|
50
|
+
import {
|
|
51
|
+
THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
|
|
52
|
+
rowsForWorld, appendLogLine,
|
|
53
|
+
} from "./viz-theme.mjs";
|
|
54
|
+
import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
|
|
55
|
+
import { renderMudEditorText, gridWorldEditorState } from "./mud-editor.mjs";
|
|
56
|
+
import {
|
|
57
|
+
pillCandidates, matchPills, pillCompleteMarkup, createPillComplete, PILL_COMPLETE_CSS,
|
|
58
|
+
} from "./pill-complete.mjs";
|
|
59
|
+
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
60
|
+
|
|
61
|
+
// The scene module and this one import each other: this file embeds the
|
|
62
|
+
// scene's generated IIFE, and the scene splices this file's pure geometry
|
|
63
|
+
// helpers into it. A static cycle is fine here because every binding crossing
|
|
64
|
+
// it is a hoisted function declaration that nothing calls at module-evaluation
|
|
65
|
+
// time — the same shape world-teach.mjs and adventure.mjs already rely on. It
|
|
66
|
+
// must NOT be a top-level `await import()`: two modules awaiting each other at
|
|
67
|
+
// evaluation time never settle, and the failure is a silent hang rather than
|
|
68
|
+
// an error.
|
|
69
|
+
import { mudiiiSceneScript } from "./mudiii-scene.mjs";
|
|
70
|
+
|
|
71
|
+
const DEFAULT_TITLE = "tmct — mudiii";
|
|
72
|
+
const DISPLAY_STACK = SERIF_STACK;
|
|
73
|
+
const SANS_STACK = `"IBM Plex Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif`;
|
|
74
|
+
|
|
75
|
+
// The deck's own detents, copied from mud-viz.mjs verbatim: every count here
|
|
76
|
+
// still divides evenly, and the sliders keep mud.html's exact ranges and
|
|
77
|
+
// defaults even though this page relabels what they size (foxes/goblins,
|
|
78
|
+
// never "players"/"npcs" — neither is a player on a one-player page).
|
|
79
|
+
const PLAYER_COUNTS = [1, 2, 4];
|
|
80
|
+
const DEFAULT_PLAYER_COUNT = 2;
|
|
81
|
+
const NPC_COUNT_MIN = 1;
|
|
82
|
+
const NPC_COUNT_MAX = 10;
|
|
83
|
+
const NPC_COUNT_LABELLED = [1, 5, 10];
|
|
84
|
+
const DEFAULT_NPC_COUNT = 2;
|
|
85
|
+
const DEFAULT_DELAY_MS = 650;
|
|
86
|
+
const DEFAULT_MAX_TURNS = 400;
|
|
87
|
+
// test/fixtures/mudiii-ticks.json's own board size — the fallback for a
|
|
88
|
+
// scenario that names no gridSize of its own.
|
|
89
|
+
const DEFAULT_GRID_SIZE = 12;
|
|
90
|
+
const DEFAULT_FACING = "south";
|
|
91
|
+
const CAMERA_MODES = ["follow", "pov", "overhead"];
|
|
92
|
+
|
|
93
|
+
const MUDIII_NOTE_LINES = [
|
|
94
|
+
"One fox and a handful of goblins share a town square, rendered in three dimensions rather than mud.html's flat rooms. The foxes slider picks how many predators are cast; the goblins slider adds more prey. Nothing here is a player either — you watch from whichever camera you pick.",
|
|
95
|
+
"Follow tracks whichever agent the dropdown names, from just behind it. POV puts the camera at that agent's own eyes, facing the way it is facing. Overhead looks straight down at the whole square, no fog of war, the same deliberate exception mud.html's own survey makes.",
|
|
96
|
+
"Click place food, then click a clear cell in the square to drop a morsel there — the same click a fox or a goblin would use to walk there, refused the same way if the cell is already taken.",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** A live agent id's kind — "fox-1" -> "fox", "goblin-3" -> "goblin" —
|
|
100
|
+
* mirroring mud-viz.mjs's own speciesOfCharacter. This is the manifest KEY
|
|
101
|
+
* a species resolves its model and clips under (data/mudiii-assets.json's
|
|
102
|
+
* own `key` column), not the engine's predator/prey ROLE tag — an agent's
|
|
103
|
+
* actual role travels on its own tick payload (`agent.role`) and is never
|
|
104
|
+
* re-derived from its id here. Pure, self-contained. */
|
|
105
|
+
export function roleOfAgentId(id) {
|
|
106
|
+
return String(id).replace(/-\d+$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A manifest row's `destPath` as a URL the page can actually fetch.
|
|
110
|
+
* `data/mudiii-assets.json` records paths from the repository root
|
|
111
|
+
* ("public/models/props/well.glb"), because that is where the checker and
|
|
112
|
+
* the credits generator read them from. The deployed site is rooted AT
|
|
113
|
+
* `public/`, so the leading segment has to come off or every model 404s
|
|
114
|
+
* under a doubled prefix. One function so the rule is written once: the
|
|
115
|
+
* loader, the cache key and any future preload all go through it. Pure. */
|
|
116
|
+
export function modelUrlFor(destPath) {
|
|
117
|
+
const path = String(destPath == null ? "" : destPath).trim();
|
|
118
|
+
if (!path) return null;
|
|
119
|
+
return `./${path.replace(/^\.?\/?public\//, "")}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `cell-<x>-<y>` (x, y both 1..gridSize) to a ground-plane world position,
|
|
123
|
+
* `{ x, z }`, centred on the grid's own middle so a `gridSize`-square board
|
|
124
|
+
* sits symmetrically around the origin regardless of size. `cellSize` is
|
|
125
|
+
* the world-unit edge length of one cell — the scene track's own convention
|
|
126
|
+
* for how many three.js units a step covers. Returns null for anything that
|
|
127
|
+
* is not a well-formed cell id, or a non-finite/non-positive `gridSize`.
|
|
128
|
+
* Pure, self-contained, no closed-over constant (gridSize/cellSize both
|
|
129
|
+
* travel as parameters for exactly that reason). The inverse is
|
|
130
|
+
* `cellFromGroundPoint`, below. */
|
|
131
|
+
export function cellToWorld(cell, gridSize, cellSize) {
|
|
132
|
+
const match = /^cell-(\d+)-(\d+)$/.exec(String(cell == null ? "" : cell));
|
|
133
|
+
if (!match) return null;
|
|
134
|
+
const size = Number(gridSize);
|
|
135
|
+
const unit = Number(cellSize);
|
|
136
|
+
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(unit)) return null;
|
|
137
|
+
const center = (size + 1) / 2;
|
|
138
|
+
return { x: (Number(match[1]) - center) * unit, z: (Number(match[2]) - center) * unit };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The inverse of `cellToWorld`: a ground-plane point `{ x, z }` to the
|
|
142
|
+
* nearest `cell-<x>-<y>` id, clamped into the board (1..gridSize on both
|
|
143
|
+
* axes) — a raycast hit a hair past the square's own edge still resolves to
|
|
144
|
+
* the edge cell rather than refusing outright. Null for a non-finite point
|
|
145
|
+
* or board. Pure, self-contained. */
|
|
146
|
+
export function cellFromGroundPoint(point, gridSize, cellSize) {
|
|
147
|
+
if (!point || typeof point.x !== "number" || typeof point.z !== "number") return null;
|
|
148
|
+
const size = Number(gridSize);
|
|
149
|
+
const unit = Number(cellSize);
|
|
150
|
+
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(unit) || unit === 0) return null;
|
|
151
|
+
const center = (size + 1) / 2;
|
|
152
|
+
const x = Math.max(1, Math.min(size, Math.round(point.x / unit + center)));
|
|
153
|
+
const y = Math.max(1, Math.min(size, Math.round(point.z / unit + center)));
|
|
154
|
+
return `cell-${x}-${y}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every static prop the world's own fact rows place, resolved against
|
|
158
|
+
* `assetManifest` (data/mudiii-assets.json's own `assets` rows) by its
|
|
159
|
+
* `mgx:model` value — a fact row never carries a file path (see this
|
|
160
|
+
* module's header), so this JOIN is the one place a `destPath` enters the
|
|
161
|
+
* page. Each returned entry is `{ id, cell, model, rotation, asset }`;
|
|
162
|
+
* `asset` is the matching manifest row, or null when the world names a
|
|
163
|
+
* model the manifest carries no key for (an honest gap, never a guessed
|
|
164
|
+
* fallback model). `rotation` defaults to "0" for a prop whose row omits
|
|
165
|
+
* it. Only rows shaped `rdf:type prop` / `mgx:currently-in` / `mgx:model` /
|
|
166
|
+
* `mgx:rotation` are read; every other predicate on the same subject is
|
|
167
|
+
* ignored. Sorted by id for a stable, testable order. Pure. */
|
|
168
|
+
export function propPlacementsFrom(rows, assetManifest) {
|
|
169
|
+
const byKey = new Map();
|
|
170
|
+
for (const row of assetManifest || []) if (row && row.key) byKey.set(row.key, row);
|
|
171
|
+
const bySubject = new Map();
|
|
172
|
+
for (const row of rows || []) {
|
|
173
|
+
if (!row || !row.subject) continue;
|
|
174
|
+
if (
|
|
175
|
+
row.predicate !== "rdf:type" && row.predicate !== "mgx:currently-in"
|
|
176
|
+
&& row.predicate !== "mgx:model" && row.predicate !== "mgx:rotation"
|
|
177
|
+
) continue;
|
|
178
|
+
if (!bySubject.has(row.subject)) bySubject.set(row.subject, { id: row.subject });
|
|
179
|
+
const entry = bySubject.get(row.subject);
|
|
180
|
+
if (row.predicate === "rdf:type") entry.type = row.object;
|
|
181
|
+
else if (row.predicate === "mgx:currently-in") entry.cell = row.object;
|
|
182
|
+
else if (row.predicate === "mgx:model") entry.model = row.object;
|
|
183
|
+
else if (row.predicate === "mgx:rotation") entry.rotation = row.object;
|
|
184
|
+
}
|
|
185
|
+
const placements = [];
|
|
186
|
+
for (const entry of bySubject.values()) {
|
|
187
|
+
if (entry.type !== "prop" || !entry.cell || !entry.model) continue;
|
|
188
|
+
placements.push({
|
|
189
|
+
id: entry.id, cell: entry.cell, model: entry.model,
|
|
190
|
+
rotation: entry.rotation || "0",
|
|
191
|
+
asset: byKey.get(entry.model) || null,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
placements.sort((a, b) => a.id.localeCompare(b.id));
|
|
195
|
+
return placements;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every cell an agent or an item currently sits on, as a `Set<string>` —
|
|
199
|
+
* the page's own quick "is anything here at all" read, e.g. for a map dot
|
|
200
|
+
* pass that wants to know which cells to draw without walking two lists
|
|
201
|
+
* twice. `agents`/`items` are plain arrays of `{ id, cell, ... }` (the
|
|
202
|
+
* shape every tick's `agents`/`items` map turns into once the page
|
|
203
|
+
* flattens it — see this file's own header on why the engine's per-turn
|
|
204
|
+
* payload is keyed by id rather than an array). Pure. */
|
|
205
|
+
export function occupiedCells(agents, items) {
|
|
206
|
+
const cells = new Set();
|
|
207
|
+
for (const agent of agents || []) if (agent && agent.cell) cells.add(agent.cell);
|
|
208
|
+
for (const item of items || []) if (item && item.cell) cells.add(item.cell);
|
|
209
|
+
return cells;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Why a click-to-move or a food placement on `cell` would be refused, or
|
|
213
|
+
* null when it is clear — `"cell-4-3 is blocked"`, worded once so both the
|
|
214
|
+
* 3D raycast click and a typed "go there" chat verb read the same refusal.
|
|
215
|
+
* A cell is blocked by a static prop (`props`, `propPlacementsFrom`'s own
|
|
216
|
+
* shape) or by any live agent standing on it — never by a loose food item,
|
|
217
|
+
* which the fixture's own readme notes a prop blocks movement and an item
|
|
218
|
+
* does not. Pure. */
|
|
219
|
+
export function blockedCellReason(cell, props, agents) {
|
|
220
|
+
const byProp = (props || []).some((p) => p && p.cell === cell);
|
|
221
|
+
const byAgent = (agents || []).some((a) => a && a.cell === cell);
|
|
222
|
+
if (byProp || byAgent) return `${cell} is blocked`;
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The camera's own position/target for `mode` ("follow" | "pov" |
|
|
227
|
+
* "overhead") against `agent` (`{ cell, facing, ... }`, or null/absent for
|
|
228
|
+
* overhead) and a `gridSize`-square board. Pure geometry only — no three.js
|
|
229
|
+
* object is built or referenced here, so the scene track can hand this
|
|
230
|
+
* straight to whatever camera primitive it uses. Assumes a one-world-unit
|
|
231
|
+
* cell (the scene track's own baseline scale; every model in
|
|
232
|
+
* data/mudiii-assets.json ships `targetHeight` in the same metres-per-unit
|
|
233
|
+
* convention), which is why this takes no separate `cellSize` the way
|
|
234
|
+
* `cellToWorld` does.
|
|
235
|
+
*
|
|
236
|
+
* "overhead" ignores `agent` entirely and looks straight down at the
|
|
237
|
+
* board's own centre. "follow" sits back and above the agent's cell, offset
|
|
238
|
+
* opposite its facing, looking at the agent. "pov" sits AT the agent's cell
|
|
239
|
+
* at eye height, looking the way it is facing. Returns null when a
|
|
240
|
+
* non-overhead mode is asked for with no agent (or an agent standing on no
|
|
241
|
+
* cell) to rig against. Pure, self-contained — calls `cellToWorld` by bare
|
|
242
|
+
* name, spliced alongside it. */
|
|
243
|
+
export function cameraRigFor(mode, agent, gridSize) {
|
|
244
|
+
const cellSize = 1;
|
|
245
|
+
if (mode === "overhead") {
|
|
246
|
+
const height = Math.max(4, Number(gridSize) || DEFAULT_GRID_SIZE) * 1.4;
|
|
247
|
+
return { mode: "overhead", position: { x: 0, y: height, z: 0 }, lookAt: { x: 0, y: 0, z: 0 } };
|
|
248
|
+
}
|
|
249
|
+
if (!agent || !agent.cell) return null;
|
|
250
|
+
const world = cellToWorld(agent.cell, gridSize, cellSize);
|
|
251
|
+
if (!world) return null;
|
|
252
|
+
const FACING_VECTOR = {
|
|
253
|
+
north: { x: 0, z: -1 }, south: { x: 0, z: 1 }, east: { x: 1, z: 0 }, west: { x: -1, z: 0 },
|
|
254
|
+
};
|
|
255
|
+
const dir = FACING_VECTOR[agent.facing] || FACING_VECTOR[DEFAULT_FACING];
|
|
256
|
+
if (mode === "pov") {
|
|
257
|
+
return {
|
|
258
|
+
mode: "pov",
|
|
259
|
+
position: { x: world.x, y: 1.6, z: world.z },
|
|
260
|
+
lookAt: { x: world.x + dir.x * 4, y: 1.4, z: world.z + dir.z * 4 },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
mode: "follow",
|
|
265
|
+
position: { x: world.x - dir.x * 4, y: 3.2, z: world.z - dir.z * 4 },
|
|
266
|
+
lookAt: { x: world.x, y: 0.8, z: world.z },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The deck's most valuable pure function: what the camera should do THIS
|
|
271
|
+
* turn given `prev`'s own selection (`{ mode, selectedId, status }`), the
|
|
272
|
+
* live `agents` (flattened `{ id, cell, ... }[]`, this turn's survivors),
|
|
273
|
+
* and `ecology` (this turn's tagged-union events, the shape
|
|
274
|
+
* test/fixtures/mudiii-ticks.json's own `_readme.ecology` documents). Five
|
|
275
|
+
* cases:
|
|
276
|
+
* 1. nothing is followed (`prev.selectedId` is null/empty) — unchanged,
|
|
277
|
+
* no status line;
|
|
278
|
+
* 2. the followed agent is still on the board — unchanged, no status
|
|
279
|
+
* line (a live follow never re-announces itself every turn);
|
|
280
|
+
* 3. it was the PREY of an `eat-agent` event this turn — falls back to
|
|
281
|
+
* overhead, naming predator and prey;
|
|
282
|
+
* 4. it was the agent of a `starve` event this turn — falls back to
|
|
283
|
+
* overhead, naming the starvation;
|
|
284
|
+
* 5. it is simply missing with no ecology event to explain it (a recast,
|
|
285
|
+
* a reset) — falls back to overhead with a generic "left the board"
|
|
286
|
+
* status, rather than staying silent about an agent the dropdown can
|
|
287
|
+
* no longer find.
|
|
288
|
+
* Pure. */
|
|
289
|
+
export function nextCameraSelection(prev, agents, ecology) {
|
|
290
|
+
const current = prev || { mode: "overhead", selectedId: null, status: null };
|
|
291
|
+
if (!current.selectedId) return { mode: current.mode, selectedId: current.selectedId, status: null };
|
|
292
|
+
const stillHere = (agents || []).some((a) => a && a.id === current.selectedId);
|
|
293
|
+
if (stillHere) return { mode: current.mode, selectedId: current.selectedId, status: null };
|
|
294
|
+
for (const event of ecology || []) {
|
|
295
|
+
if (event && event.type === "eat-agent" && event.prey === current.selectedId) {
|
|
296
|
+
return {
|
|
297
|
+
mode: "overhead", selectedId: null,
|
|
298
|
+
status: `${event.predator} ate ${event.prey} — switching to overhead`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (event && event.type === "starve" && event.agent === current.selectedId) {
|
|
302
|
+
return { mode: "overhead", selectedId: null, status: `${event.agent} starved — switching to overhead` };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
mode: "overhead", selectedId: null,
|
|
307
|
+
status: `${current.selectedId} left the board — switching to overhead`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Every agent and item's own dot for the 2D top-down map panel, as
|
|
312
|
+
* percentage coordinates within a square panel (`{ id, kind, xPct, yPct
|
|
313
|
+
* }[]`) — never pixel values, so the panel's own CSS controls its actual
|
|
314
|
+
* size. `agents`/`items` are flattened `{ id, cell, ... }[]`; an agent's
|
|
315
|
+
* `kind` is its own `role` field (predator/prey) and an item's is its own
|
|
316
|
+
* `kind` field (crumb/morsel) — the caller's tick payload already carries
|
|
317
|
+
* both under those exact names. An entry with no parseable cell is
|
|
318
|
+
* dropped rather than drawn at a guessed position. Pure, self-contained. */
|
|
319
|
+
export function mapDotsFor(agents, items, gridSize) {
|
|
320
|
+
function percentFor(cell) {
|
|
321
|
+
const match = /^cell-(\d+)-(\d+)$/.exec(String(cell == null ? "" : cell));
|
|
322
|
+
if (!match) return null;
|
|
323
|
+
const size = Number(gridSize);
|
|
324
|
+
if (!Number.isFinite(size) || size <= 0) return null;
|
|
325
|
+
return { xPct: ((Number(match[1]) - 0.5) / size) * 100, yPct: ((Number(match[2]) - 0.5) / size) * 100 };
|
|
326
|
+
}
|
|
327
|
+
const dots = [];
|
|
328
|
+
for (const agent of agents || []) {
|
|
329
|
+
const pct = agent && percentFor(agent.cell);
|
|
330
|
+
if (pct) dots.push({ id: agent.id, kind: agent.role || "agent", xPct: pct.xPct, yPct: pct.yPct });
|
|
331
|
+
}
|
|
332
|
+
for (const item of items || []) {
|
|
333
|
+
const pct = item && percentFor(item.cell);
|
|
334
|
+
if (pct) dots.push({ id: item.id, kind: item.kind || "item", xPct: pct.xPct, yPct: pct.yPct });
|
|
335
|
+
}
|
|
336
|
+
return dots;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** One HUD card's own field set, read off `agent` (`{ id, role, goal, mood,
|
|
340
|
+
* plan, mass, belief }`, this turn's slice of the tick payload) and a
|
|
341
|
+
* resolved `mudiiiConfig` (DEFAULT_GAME_CONFIG.mudiii's own shape). `massPct`
|
|
342
|
+
* is null whenever the role carries no mass ceiling to scale against
|
|
343
|
+
* (mirrors game-config.mjs's own `mudiiiMassScaleFor`, reimplemented here
|
|
344
|
+
* rather than imported so this stays `.toString()`-splice safe with no
|
|
345
|
+
* closed-over module). `planText` reads "holding" for an empty plan, the
|
|
346
|
+
* same wording spider-fly-viz.mjs's own `planLineHtml` uses. `beliefEntries`
|
|
347
|
+
* is `Object.entries(agent.belief)` unmodified — the caller renders it,
|
|
348
|
+
* this only shapes it. Pure. */
|
|
349
|
+
export function hudCardFieldsFor(agent, config) {
|
|
350
|
+
const role = agent && agent.role ? agent.role : null;
|
|
351
|
+
const scale = role === "predator" ? config?.predatorInitialMass : role === "prey" ? config?.preyInitialMass : null;
|
|
352
|
+
const mass = agent && typeof agent.mass === "number" ? agent.mass : null;
|
|
353
|
+
const massPct = scale && mass !== null ? Math.max(0, Math.min(100, Math.round((mass / scale) * 100))) : null;
|
|
354
|
+
const plan = agent && Array.isArray(agent.plan) ? agent.plan : [];
|
|
355
|
+
return {
|
|
356
|
+
id: agent ? agent.id : null,
|
|
357
|
+
role,
|
|
358
|
+
goal: (agent && agent.goal) || "",
|
|
359
|
+
mood: (agent && agent.mood) || "",
|
|
360
|
+
mass,
|
|
361
|
+
massPct,
|
|
362
|
+
planText: plan.length ? plan.join(" → ") : "holding",
|
|
363
|
+
beliefEntries: agent && agent.belief ? Object.entries(agent.belief) : [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The animation clip name for `action` (an engine-level rung/event word —
|
|
368
|
+
* "wander", "chase", "evade", "forage", "eat-agent", "eat-item", "starve")
|
|
369
|
+
* against one creature's own `clipMap` (data/mudiii-assets.json's `clips`
|
|
370
|
+
* object for that asset — `{ idle, walk, run, attack, hit, death, eat }`,
|
|
371
|
+
* some values themselves arrays). `role` is the engine's predator/prey tag,
|
|
372
|
+
* which only matters for `eat-agent`: the predator plays its attack, the
|
|
373
|
+
* prey (briefly still animate the instant it is caught) plays its death.
|
|
374
|
+
* Falls back to `clipMap.idle`, then null, for an action/role combination
|
|
375
|
+
* the map carries no clip for (goblin.glb ships no "eat" clip — see the
|
|
376
|
+
* manifest's own row — so `clipForAction("prey", "eat-item", ...)` lands on
|
|
377
|
+
* idle rather than a guessed name). An array-valued clip (the two rigs'
|
|
378
|
+
* own `hit` list) returns its first entry. Pure, self-contained. */
|
|
379
|
+
export function clipForAction(role, action, clipMap) {
|
|
380
|
+
const clips = clipMap || {};
|
|
381
|
+
const kindFor = {
|
|
382
|
+
wander: "walk",
|
|
383
|
+
forage: "walk",
|
|
384
|
+
chase: "run",
|
|
385
|
+
evade: "run",
|
|
386
|
+
"eat-agent": role === "predator" ? "attack" : "death",
|
|
387
|
+
"eat-item": "eat",
|
|
388
|
+
starve: "death",
|
|
389
|
+
death: "death",
|
|
390
|
+
idle: "idle",
|
|
391
|
+
};
|
|
392
|
+
const wanted = kindFor[action] || "idle";
|
|
393
|
+
const clip = clips[wanted];
|
|
394
|
+
const resolved = Array.isArray(clip) ? clip[0] : clip;
|
|
395
|
+
return resolved || clips.idle || null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** One HUD card's whole markup, character-agnostic — mirrors mud-viz.mjs's
|
|
399
|
+
* own `paneMarkup`: which agent it shows is stamped in at render time
|
|
400
|
+
* (`data-agent`, the id/role text), so the row can be rebuilt for a
|
|
401
|
+
* different cast without a second template living anywhere else. `slot` is
|
|
402
|
+
* an opaque per-card key (the page uses the agent's position in the sorted
|
|
403
|
+
* id list); this function reads nothing from it beyond namespacing its own
|
|
404
|
+
* element ids. Pure, self-contained (escapeHtml is spliced alongside it). */
|
|
405
|
+
export function agentCardMarkup(slot) {
|
|
406
|
+
const w = `hud-${escapeHtml(String(slot))}`;
|
|
407
|
+
return `<div class="hud-card" id="${w}" data-slot="${escapeHtml(String(slot))}" data-agent="">
|
|
408
|
+
<div class="hud-card-head">
|
|
409
|
+
<span class="hud-card-id mono" id="${w}-id"></span>
|
|
410
|
+
<span class="hud-card-role mono" id="${w}-role"></span>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="hud-meter" id="${w}-meter"><div class="hud-meter-fill" id="${w}-meter-fill"></div></div>
|
|
413
|
+
<p class="hud-goal" id="${w}-goal"></p>
|
|
414
|
+
<p class="hud-plan mono" id="${w}-plan"></p>
|
|
415
|
+
<p class="hud-belief mono" id="${w}-belief"></p>
|
|
416
|
+
</div>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The self-contained mudiii.html page. Pure — identical output for
|
|
420
|
+
* identical input; the live world, the ticking simulation and the 3D scene
|
|
421
|
+
* are all computed in the browser once the sibling bundle loads.
|
|
422
|
+
*
|
|
423
|
+
* `worldPayload` is `{ name, facts, rules, opening }`, the same shape every
|
|
424
|
+
* other viz page's world payload takes. `agents` is the roster this page
|
|
425
|
+
* may cast from, `[{ id, role }]` (role is "predator" or "prey" — see
|
|
426
|
+
* MUDIII_ROLES). `scenarios` is `[{ label, worldPayload, agents, gridSize }]`
|
|
427
|
+
* — a page given none is the one-square case and ships no dropdown, the
|
|
428
|
+
* same rule mud-viz.mjs's own renderMudHtml follows. `assetManifest` is
|
|
429
|
+
* data/mudiii-assets.json's own `assets` rows, embedded as page data and
|
|
430
|
+
* resolved client-side by `propPlacementsFrom` — this is the one parameter
|
|
431
|
+
* mud-viz.mjs's renderMudHtml has no equivalent of, because mud.html has no
|
|
432
|
+
* file-backed models to resolve. `mudiiiConfig` defaults to
|
|
433
|
+
* game-config.mjs's own DEFAULT_GAME_CONFIG.mudiii. `engineBundleJs` inlines
|
|
434
|
+
* the built browser bundle instead of a sibling `<script src>`, the same
|
|
435
|
+
* standalone-export knob every other viz page carries. */
|
|
436
|
+
export function renderMudiiiHtml({
|
|
437
|
+
title = DEFAULT_TITLE,
|
|
438
|
+
worldPayload,
|
|
439
|
+
agents = [],
|
|
440
|
+
scenarios = [],
|
|
441
|
+
assetManifest = [],
|
|
442
|
+
mudiiiConfig = DEFAULT_GAME_CONFIG.mudiii,
|
|
443
|
+
engineBundleJs = "",
|
|
444
|
+
} = {}) {
|
|
445
|
+
const scenarioList = scenarios.length
|
|
446
|
+
? scenarios
|
|
447
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload, agents }];
|
|
448
|
+
const opening = scenarioList[0];
|
|
449
|
+
const openingAgents = opening.agents || [];
|
|
450
|
+
const gridSize = opening.gridSize || DEFAULT_GRID_SIZE;
|
|
451
|
+
|
|
452
|
+
const pageData = embedJson({
|
|
453
|
+
scenarios: scenarioList, assetManifest, mudiiiConfig,
|
|
454
|
+
playerCounts: PLAYER_COUNTS,
|
|
455
|
+
defaultPlayerCount: DEFAULT_PLAYER_COUNT,
|
|
456
|
+
defaultNpcCount: DEFAULT_NPC_COUNT,
|
|
457
|
+
defaultDelayMs: DEFAULT_DELAY_MS,
|
|
458
|
+
defaultMaxTurns: DEFAULT_MAX_TURNS,
|
|
459
|
+
gridSize,
|
|
460
|
+
defaultFacing: DEFAULT_FACING,
|
|
461
|
+
cameraModes: CAMERA_MODES,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const sceneScript = mudiiiSceneScript({ canvasId: "sceneCanvas", statusId: "sceneStatus", gridSize, cellSize: 1 });
|
|
465
|
+
|
|
466
|
+
return `<!doctype html>
|
|
467
|
+
<html lang="en">
|
|
468
|
+
<head>
|
|
469
|
+
<meta charset="utf-8">
|
|
470
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
471
|
+
<title>${escapeHtml(title)}</title>
|
|
472
|
+
${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+xml">
|
|
473
|
+
<link rel="icon" href="./favicon.ico" sizes="any">
|
|
474
|
+
<link rel="apple-touch-icon" href="./apple-touch-icon.png">`}
|
|
475
|
+
<style>
|
|
476
|
+
${THEME_TOKENS_CSS}
|
|
477
|
+
${MUDIII_STYLE}
|
|
478
|
+
${PILL_COMPLETE_CSS}
|
|
479
|
+
</style>
|
|
480
|
+
</head>
|
|
481
|
+
<body>
|
|
482
|
+
<main>
|
|
483
|
+
<header class="mudiii-topbar">
|
|
484
|
+
<h1 class="eyebrow">tmct · mudiii</h1>
|
|
485
|
+
<a class="mudiii-topbar-help" href="./help.html" target="_blank" rel="noopener"
|
|
486
|
+
title="how this demo works, in a new tab" aria-label="how this demo works, opens in a new tab">?</a>
|
|
487
|
+
</header>
|
|
488
|
+
<div class="deck-row">
|
|
489
|
+
<section class="deck" aria-label="simulation controls">
|
|
490
|
+
<div class="deck-controls">
|
|
491
|
+
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
492
|
+
<button type="button" id="resetBtn">reset</button>
|
|
493
|
+
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which town square to play">
|
|
494
|
+
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
495
|
+
</select>` : ""}
|
|
496
|
+
<button type="button" id="editModeBtn" aria-pressed="false">edit</button>
|
|
497
|
+
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
498
|
+
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
499
|
+
</div>
|
|
500
|
+
<div class="deck-sliders">
|
|
501
|
+
<label class="deck-slider">foxes
|
|
502
|
+
<input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
|
|
503
|
+
value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
|
|
504
|
+
list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
|
|
505
|
+
<datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
|
|
506
|
+
<span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
|
|
507
|
+
</label>
|
|
508
|
+
<label class="deck-slider">goblins
|
|
509
|
+
<input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
|
|
510
|
+
value="${DEFAULT_NPC_COUNT}"
|
|
511
|
+
list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
|
|
512
|
+
<datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
|
|
513
|
+
const n = NPC_COUNT_MIN + i;
|
|
514
|
+
return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
|
|
515
|
+
}).join("")}</datalist>
|
|
516
|
+
<span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
|
|
517
|
+
</label>
|
|
518
|
+
<label class="deck-slider">delay
|
|
519
|
+
<input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
|
|
520
|
+
<span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
|
|
521
|
+
</label>
|
|
522
|
+
<label class="deck-slider">max turns
|
|
523
|
+
<input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
|
|
524
|
+
<span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
|
|
525
|
+
</label>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="deck-camera">
|
|
528
|
+
<label class="deck-slider">follow
|
|
529
|
+
<select id="agentSelect" class="deck-select" aria-label="which agent to follow">
|
|
530
|
+
${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
|
|
531
|
+
</select>
|
|
532
|
+
</label>
|
|
533
|
+
<div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
|
|
534
|
+
<button type="button" data-mode="follow" aria-pressed="true">follow</button>
|
|
535
|
+
<button type="button" data-mode="pov" aria-pressed="false">pov</button>
|
|
536
|
+
<button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
|
|
537
|
+
</div>
|
|
538
|
+
<button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="deck-info-popup mudiii-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
541
|
+
${MUDIII_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
542
|
+
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
543
|
+
</div>
|
|
544
|
+
</section>
|
|
545
|
+
<section class="map-panel" id="mapPanel" aria-label="the town square, from above">
|
|
546
|
+
<div class="map-panel-head">
|
|
547
|
+
<span class="map-panel-title">the square, from above</span>
|
|
548
|
+
<span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
|
|
549
|
+
</div>
|
|
550
|
+
<div class="map-panel-board" id="mapPanelBoard"></div>
|
|
551
|
+
</section>
|
|
552
|
+
</div>
|
|
553
|
+
<section class="scene-stage" id="sceneStage" aria-label="the town square, in three dimensions">
|
|
554
|
+
<canvas id="sceneCanvas"></canvas>
|
|
555
|
+
<p class="scene-status" id="sceneStatus" role="status"></p>
|
|
556
|
+
</section>
|
|
557
|
+
<div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
|
|
558
|
+
<div class="edit-stage" id="mudiiiEditStage" aria-label="the square's own facts, in plain sentences">
|
|
559
|
+
<section class="edit-text" aria-label="the world's facts as editable sentences">
|
|
560
|
+
<h2>the square, in plain sentences</h2>
|
|
561
|
+
<p class="edit-lede">Every fact this world is built from. Change a line and the square changes with it.</p>
|
|
562
|
+
<textarea id="editorText" spellcheck="false" aria-label="the world's own facts as plain sentences, one per line"></textarea>
|
|
563
|
+
<div class="chatpills" id="editorPills" aria-label="related words for the term before the cursor"></div>
|
|
564
|
+
<p class="edit-status" id="editorStatus" role="status"></p>
|
|
565
|
+
</section>
|
|
566
|
+
<aside class="edit-side" aria-label="who and what stands where">
|
|
567
|
+
<section class="edit-panel">
|
|
568
|
+
<h2>who and what stands where</h2>
|
|
569
|
+
<div class="edit-placements" id="editPlacements"></div>
|
|
570
|
+
</section>
|
|
571
|
+
</aside>
|
|
572
|
+
</div>
|
|
573
|
+
<section class="mudiii-chat" aria-label="talk to the square">
|
|
574
|
+
<div class="chatlog" id="chatLog" aria-live="polite"></div>
|
|
575
|
+
<div class="log-popup" id="chatLogPopup" role="dialog" aria-label="the whole reply" hidden>
|
|
576
|
+
<p class="log-popup-text" id="chatLogPopupText"></p>
|
|
577
|
+
<button type="button" class="log-popup-close" id="chatLogPopupClose" aria-label="close the whole reply">×</button>
|
|
578
|
+
</div>
|
|
579
|
+
<div class="chat-console">
|
|
580
|
+
<div class="pill-strip">
|
|
581
|
+
<div class="chatpills" id="chatPills" role="group" aria-label="quick commands"></div>
|
|
582
|
+
</div>
|
|
583
|
+
<form class="chatask" id="chatForm">
|
|
584
|
+
<span class="prompt mono">tmct></span>
|
|
585
|
+
${pillCompleteMarkup({
|
|
586
|
+
inputId: "chatInput",
|
|
587
|
+
inputHtml: '<input id="chatInput" type="text" placeholder="@fox-1 look" aria-label="type a command" disabled>',
|
|
588
|
+
})}
|
|
589
|
+
</form>
|
|
590
|
+
</div>
|
|
591
|
+
</section>
|
|
592
|
+
</main>
|
|
593
|
+
<script>
|
|
594
|
+
const MUDIII_PAGE_DATA = ${pageData};
|
|
595
|
+
</script>
|
|
596
|
+
${sceneScript ? `<script>\n${embedScriptText(sceneScript)}\n</script>` : ""}
|
|
597
|
+
${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./mudiii-browser.bundle.js"></script>`}
|
|
598
|
+
<script>
|
|
599
|
+
${embedScriptText(pageScript())}
|
|
600
|
+
</script>
|
|
601
|
+
</body>
|
|
602
|
+
</html>
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const MUDIII_STYLE = `
|
|
607
|
+
:root {
|
|
608
|
+
--square-sky: #BFE3F0; --square-horizon: #E9D9B6; --square-stone: #8C8172;
|
|
609
|
+
--square-stone-dark: #59503F; --square-thatch: #B5702E; --parchment: #F3ECDD;
|
|
610
|
+
--square-ink: #2B2318; --square-accent: #D98A2B; --square-grass: #7C9A5B;
|
|
611
|
+
--square-predator: #B5502C; --square-prey: #4E7C5B;
|
|
612
|
+
}
|
|
613
|
+
html { background: var(--square-stone-dark); }
|
|
614
|
+
body { margin: 0; background: linear-gradient(180deg, var(--square-sky) 0%, var(--square-horizon) 40%, var(--square-stone) 100%) fixed; color: var(--square-ink); font-family: ${SANS_STACK}; font-size: 15px; line-height: 1.5; }
|
|
615
|
+
.mono { font-family: ${MONO_STACK}; }
|
|
616
|
+
main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
|
|
617
|
+
.eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--square-ink); opacity: .85; margin: 0; }
|
|
618
|
+
h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; }
|
|
619
|
+
h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--square-stone-dark); }
|
|
620
|
+
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
621
|
+
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--square-accent); outline-offset: 2px; }
|
|
622
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
623
|
+
|
|
624
|
+
.mudiii-topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0 0 .8rem; }
|
|
625
|
+
.mudiii-topbar-help {
|
|
626
|
+
background: var(--parchment); color: var(--square-ink); border: 1px solid var(--square-stone-dark); border-radius: 3px;
|
|
627
|
+
padding: .32rem .55rem; text-decoration: none; font-size: .82rem; line-height: 1.2;
|
|
628
|
+
}
|
|
629
|
+
.mudiii-topbar-help:hover { border-color: var(--square-accent); }
|
|
630
|
+
|
|
631
|
+
.deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
|
|
632
|
+
.deck {
|
|
633
|
+
position: relative;
|
|
634
|
+
background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
|
|
635
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.35);
|
|
636
|
+
padding: .8rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0;
|
|
637
|
+
}
|
|
638
|
+
.deck-controls, .deck-camera { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
639
|
+
.deck-info-btn {
|
|
640
|
+
font-family: ${MONO_STACK}; font-size: .78rem; line-height: 1; width: 1.5rem; height: 1.5rem;
|
|
641
|
+
border-radius: 50%; border: 1px solid var(--square-stone-dark); background: rgba(255,255,255,.5);
|
|
642
|
+
color: var(--square-stone-dark); padding: 0; flex: 0 0 auto;
|
|
643
|
+
}
|
|
644
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { border-color: var(--square-accent); color: var(--square-ink); }
|
|
645
|
+
.deck button, .camera-mode button {
|
|
646
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
647
|
+
padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px; background: rgba(255,255,255,.5);
|
|
648
|
+
}
|
|
649
|
+
.deck button:hover:not(:disabled), .camera-mode button:hover:not(:disabled) { border-color: var(--square-accent); }
|
|
650
|
+
.deck-select {
|
|
651
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
652
|
+
padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px;
|
|
653
|
+
background: rgba(255,255,255,.5); color: var(--square-ink);
|
|
654
|
+
}
|
|
655
|
+
.deck-select:hover { border-color: var(--square-accent); }
|
|
656
|
+
.deck-play { background: var(--square-ink) !important; color: var(--parchment); border-color: var(--square-ink) !important; padding: .38rem 1.1rem !important; }
|
|
657
|
+
.deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
|
|
658
|
+
.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; }
|
|
659
|
+
.deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
660
|
+
.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); }
|
|
661
|
+
.deck-slider input[type="range"] { accent-color: var(--square-accent); width: 8rem; max-width: 34vw; }
|
|
662
|
+
.camera-mode { display: inline-flex; gap: .25rem; }
|
|
663
|
+
.camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
|
|
664
|
+
.deck-info-popup {
|
|
665
|
+
position: absolute; left: .9rem; right: .9rem; top: calc(100% + 8px); z-index: 8;
|
|
666
|
+
background: var(--square-stone-dark); color: var(--parchment);
|
|
667
|
+
border: 1px solid var(--square-accent); border-radius: 4px;
|
|
668
|
+
padding: .55rem 1.6rem .6rem .65rem; box-shadow: 0 8px 18px rgba(0,0,0,.5);
|
|
669
|
+
}
|
|
670
|
+
.deck-info-popup::before {
|
|
671
|
+
content: ""; position: absolute; left: 1.1rem; top: -6px; width: 0; height: 0;
|
|
672
|
+
border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--square-accent);
|
|
673
|
+
}
|
|
674
|
+
.deck-info-popup p { margin: 0 0 .4rem; max-width: 62ch; font-size: .78rem; line-height: 1.4; }
|
|
675
|
+
.deck-info-popup p:last-child { margin-bottom: 0; }
|
|
676
|
+
.deck-info-popup-close { position: absolute; top: .1rem; right: .3rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
677
|
+
.deck-info-popup-close:hover { color: var(--square-accent); }
|
|
678
|
+
|
|
679
|
+
.map-panel {
|
|
680
|
+
background: var(--square-stone-dark); color: var(--parchment);
|
|
681
|
+
border: 1px solid var(--square-accent); border-radius: 4px; padding: .55rem .65rem .6rem;
|
|
682
|
+
display: flex; flex-direction: column; gap: .4rem; min-width: 0;
|
|
683
|
+
}
|
|
684
|
+
.map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
|
|
685
|
+
.map-panel-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
686
|
+
.map-panel-turn { font-size: .62rem; opacity: .7; }
|
|
687
|
+
.map-panel-board { position: relative; flex: 1; min-height: 200px; background: rgba(124,154,91,.25); border: 1px solid rgba(233,217,182,.35); border-radius: 3px; }
|
|
688
|
+
.map-dot { position: absolute; width: .55rem; height: .55rem; margin: -.28rem 0 0 -.28rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
|
|
689
|
+
.map-dot-predator { background: var(--square-predator); }
|
|
690
|
+
.map-dot-prey { background: var(--square-prey); }
|
|
691
|
+
.map-dot-crumb, .map-dot-morsel, .map-dot-item { background: var(--square-accent); width: .34rem; height: .34rem; margin: -.17rem 0 0 -.17rem; }
|
|
692
|
+
|
|
693
|
+
.scene-stage { position: relative; margin-bottom: 1rem; border: 1px solid var(--square-stone-dark); border-radius: 4px; overflow: hidden; background: #10161B; min-height: 360px; }
|
|
694
|
+
.scene-stage canvas { display: block; width: 100%; height: 360px; }
|
|
695
|
+
.scene-status {
|
|
696
|
+
position: absolute; left: .6rem; bottom: .6rem; margin: 0; max-width: calc(100% - 1.2rem);
|
|
697
|
+
font-family: ${MONO_STACK}; font-size: .68rem; color: var(--parchment); background: rgba(43,35,24,.78);
|
|
698
|
+
border-radius: 3px; padding: .28rem .55rem;
|
|
699
|
+
}
|
|
700
|
+
.scene-status:empty { display: none; }
|
|
701
|
+
|
|
702
|
+
.hud-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: 1rem; }
|
|
703
|
+
.hud-card {
|
|
704
|
+
flex: 1 1 220px; min-width: 200px; max-width: 320px;
|
|
705
|
+
background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
|
|
706
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .55rem .65rem; display: flex; flex-direction: column; gap: .3rem;
|
|
707
|
+
}
|
|
708
|
+
.hud-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: .4rem; }
|
|
709
|
+
.hud-card-id { font-size: .78rem; font-weight: 600; }
|
|
710
|
+
.hud-card-role { font-size: .6rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
|
|
711
|
+
.hud-meter { height: .4rem; background: rgba(0,0,0,.15); border-radius: 99px; overflow: hidden; }
|
|
712
|
+
.hud-meter-fill { height: 100%; background: var(--square-accent); width: 0%; transition: width .3s ease; }
|
|
713
|
+
.hud-goal { margin: 0; font-size: .74rem; }
|
|
714
|
+
.hud-plan, .hud-belief { margin: 0; font-size: .62rem; color: var(--square-stone-dark); }
|
|
715
|
+
@media (prefers-reduced-motion: reduce) { .hud-meter-fill { transition: none; } }
|
|
716
|
+
|
|
717
|
+
.edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; margin-bottom: 1rem; }
|
|
718
|
+
body.editing .mudiii-chat, body.editing .scene-stage, body.editing .hud-row, body.editing .deck-row .map-panel { display: none; }
|
|
719
|
+
body.editing .deck-row { grid-template-columns: 1fr; }
|
|
720
|
+
body.editing .edit-stage { display: grid; }
|
|
721
|
+
#editModeBtn[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); }
|
|
722
|
+
.edit-text, .edit-panel {
|
|
723
|
+
background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
|
|
724
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .7rem .8rem; min-width: 0;
|
|
725
|
+
}
|
|
726
|
+
.edit-text { display: flex; flex-direction: column; gap: .45rem; }
|
|
727
|
+
.edit-lede { margin: 0; font-size: .74rem; color: var(--square-stone-dark); max-width: 62ch; }
|
|
728
|
+
#editorText {
|
|
729
|
+
width: 100%; box-sizing: border-box; min-height: 22rem; flex: 1 1 auto; resize: vertical;
|
|
730
|
+
font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.6;
|
|
731
|
+
color: var(--square-ink); background: rgba(255,255,255,.6);
|
|
732
|
+
border: 1px solid var(--square-stone-dark); border-radius: 3px; padding: .55rem .6rem;
|
|
733
|
+
}
|
|
734
|
+
.edit-status { margin: 0; font-family: ${MONO_STACK}; font-size: .62rem; min-height: 1.2em; color: var(--square-stone-dark); }
|
|
735
|
+
.edit-status.pending { color: #9A5B12; }
|
|
736
|
+
.edit-status.ok { color: #4E6B2E; }
|
|
737
|
+
.edit-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
738
|
+
.edit-panel { display: flex; flex-direction: column; gap: .35rem; }
|
|
739
|
+
.edit-placements { display: flex; flex-direction: column; gap: .2rem; font-size: .74rem; }
|
|
740
|
+
.edit-placement-row { display: flex; gap: .4rem; }
|
|
741
|
+
.edit-empty { font-size: .74rem; font-style: italic; color: var(--square-stone-dark); }
|
|
742
|
+
|
|
743
|
+
.mudiii-chat { background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px; box-shadow: 0 2px 0 rgba(0,0,0,.18); padding: .7rem .8rem; display: flex; flex-direction: column; gap: .5rem; }
|
|
744
|
+
.chatlog { min-height: 8rem; max-height: 16rem; overflow-y: auto; }
|
|
745
|
+
.chatlog > * { margin: 0 0 .32rem; }
|
|
746
|
+
.chatlog:empty::after { content: "Type a command below and the answer lands here."; color: var(--square-stone-dark); font-style: italic; font-size: .72rem; }
|
|
747
|
+
.chatlog .u { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--square-stone-dark); overflow-wrap: anywhere; }
|
|
748
|
+
.chatlog .u::before { content: "tmct> "; color: var(--square-accent); }
|
|
749
|
+
.chatlog .a { font-size: .78rem; line-height: 1.4; }
|
|
750
|
+
.log-popup {
|
|
751
|
+
position: relative; z-index: 8; background: var(--square-stone-dark); color: var(--parchment);
|
|
752
|
+
border: 1px solid var(--square-accent); border-radius: 4px; padding: .45rem 1.5rem .5rem .55rem;
|
|
753
|
+
}
|
|
754
|
+
.log-popup-text { margin: 0; font-size: .74rem; line-height: 1.4; }
|
|
755
|
+
.log-popup-close { position: absolute; top: .1rem; right: .25rem; font-size: 1rem; line-height: 1; color: var(--parchment); padding: .1rem .2rem; }
|
|
756
|
+
.chat-console { display: flex; flex-direction: column; gap: .3rem; }
|
|
757
|
+
.chatask { display: flex; align-items: center; gap: .4rem; }
|
|
758
|
+
.chatask .prompt { color: var(--square-accent); font-size: .7rem; }
|
|
759
|
+
.chatask .pc-field { flex: 1; min-width: 0; }
|
|
760
|
+
.chatask input {
|
|
761
|
+
width: 100%; box-sizing: border-box; font-family: ${MONO_STACK}; font-size: .74rem; color: var(--square-ink);
|
|
762
|
+
border: 1px solid var(--square-stone-dark); border-radius: 2px; padding: .3rem .5rem;
|
|
763
|
+
}
|
|
764
|
+
.chatpills { display: flex; flex-wrap: wrap; gap: .22rem; max-height: 3.9rem; overflow-y: auto; }
|
|
765
|
+
.chatpills:empty { display: none; }
|
|
766
|
+
.pill { font-family: ${MONO_STACK}; font-size: .58rem; padding: .14rem .45rem; border: 1px solid var(--square-stone-dark); border-radius: 99px; background: rgba(255,255,255,.5); white-space: nowrap; }
|
|
767
|
+
.pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
|
|
768
|
+
.pill.affordance { border-style: dashed; border-color: var(--square-stone); }
|
|
769
|
+
.pill.affordance[aria-pressed="true"] { background: var(--square-accent); border-style: solid; }
|
|
770
|
+
|
|
771
|
+
@media (max-width: 900px) {
|
|
772
|
+
.deck-row { grid-template-columns: 1fr; }
|
|
773
|
+
.edit-stage { grid-template-columns: 1fr; }
|
|
774
|
+
#editorText { min-height: 16rem; }
|
|
775
|
+
}
|
|
776
|
+
`;
|
|
777
|
+
|
|
778
|
+
/** The inlined page script, spliced the same way mud-viz.mjs's own
|
|
779
|
+
* `pageScript` is: every helper this page needs client-side is listed at
|
|
780
|
+
* the top of the closure as a `const` binding built off that function's own
|
|
781
|
+
* `.toString()`. The scene track's own script (see `renderMudiiiHtml`'s
|
|
782
|
+
* `sceneScript`) is a SEPARATE, standalone `<script>` tag rather than one of
|
|
783
|
+
* these bindings — it is a whole IIFE of its own, not a helper function this
|
|
784
|
+
* page's closure calls into. */
|
|
785
|
+
function pageScript() {
|
|
786
|
+
return `(function () {
|
|
787
|
+
"use strict";
|
|
788
|
+
const DATA = MUDIII_PAGE_DATA;
|
|
789
|
+
const createTicker = ${createTicker.toString()};
|
|
790
|
+
const createSerialQueue = ${createSerialQueue.toString()};
|
|
791
|
+
const escapeHtml = ${escapeHtml.toString()};
|
|
792
|
+
const esc = escapeHtml;
|
|
793
|
+
const appendLogLine = ${appendLogLine.toString()};
|
|
794
|
+
const rowsForWorld = ${rowsForWorld.toString()};
|
|
795
|
+
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
796
|
+
const gridWorldEditorState = ${gridWorldEditorState.toString()};
|
|
797
|
+
const pillCandidates = ${pillCandidates.toString()};
|
|
798
|
+
const matchPills = ${matchPills.toString()};
|
|
799
|
+
const createPillComplete = ${createPillComplete.toString()};
|
|
800
|
+
const roleOfAgentId = ${roleOfAgentId.toString()};
|
|
801
|
+
const cellToWorld = ${cellToWorld.toString()};
|
|
802
|
+
const cellFromGroundPoint = ${cellFromGroundPoint.toString()};
|
|
803
|
+
const propPlacementsFrom = ${propPlacementsFrom.toString()};
|
|
804
|
+
const occupiedCells = ${occupiedCells.toString()};
|
|
805
|
+
const blockedCellReason = ${blockedCellReason.toString()};
|
|
806
|
+
const cameraRigFor = ${cameraRigFor.toString()};
|
|
807
|
+
const nextCameraSelection = ${nextCameraSelection.toString()};
|
|
808
|
+
const mapDotsFor = ${mapDotsFor.toString()};
|
|
809
|
+
const hudCardFieldsFor = ${hudCardFieldsFor.toString()};
|
|
810
|
+
const clipForAction = ${clipForAction.toString()};
|
|
811
|
+
const agentCardMarkup = ${agentCardMarkup.toString()};
|
|
812
|
+
|
|
813
|
+
const el = (id) => document.getElementById(id);
|
|
814
|
+
let scenarioIndex = 0;
|
|
815
|
+
const scenario = function () { return DATA.scenarios[scenarioIndex]; };
|
|
816
|
+
const rosterOf = function (s, role) {
|
|
817
|
+
return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// ---- roster picking: the same shuffle-and-slice mud-browser-entry.mjs's
|
|
821
|
+
// own pickMudRoster performs, kept here rather than in the bundle because
|
|
822
|
+
// it needs no engine at all and this page's own tests exercise it through
|
|
823
|
+
// the rendered controls, never directly.
|
|
824
|
+
function pickRoster(roster, count) {
|
|
825
|
+
const pool = [...(roster || [])];
|
|
826
|
+
for (let i = pool.length - 1; i > 0; i -= 1) {
|
|
827
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
828
|
+
const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
|
|
829
|
+
}
|
|
830
|
+
return pool.slice(0, Math.min(count, pool.length));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let cast = [];
|
|
834
|
+
let props = [];
|
|
835
|
+
let agentsById = {};
|
|
836
|
+
let itemsById = {};
|
|
837
|
+
let globalTurn = 0;
|
|
838
|
+
let maxTurns = DATA.defaultMaxTurns;
|
|
839
|
+
let delayMs = DATA.defaultDelayMs;
|
|
840
|
+
let session = null;
|
|
841
|
+
let tickQueue = createSerialQueue();
|
|
842
|
+
function serializeTick(fn) { return tickQueue.run(fn); }
|
|
843
|
+
let camera = { mode: "follow", selectedId: null, status: null };
|
|
844
|
+
let foodArmed = false;
|
|
845
|
+
let livePills = [];
|
|
846
|
+
let pillComplete = null;
|
|
847
|
+
let autoOn = false;
|
|
848
|
+
let editing = false;
|
|
849
|
+
let ticker = null;
|
|
850
|
+
|
|
851
|
+
function hasNext() { return globalTurn < maxTurns; }
|
|
852
|
+
const wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); };
|
|
853
|
+
const liveWait = function () { return wait(delayMs); };
|
|
854
|
+
|
|
855
|
+
function agentsList() {
|
|
856
|
+
return Object.keys(agentsById).map(function (id) { return Object.assign({ id: id }, agentsById[id]); });
|
|
857
|
+
}
|
|
858
|
+
function itemsList() {
|
|
859
|
+
return Object.keys(itemsById).map(function (id) { return Object.assign({ id: id }, itemsById[id]); });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function setSceneStatus(text) {
|
|
863
|
+
const node = el("sceneStatus");
|
|
864
|
+
if (node) node.textContent = text || "";
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// The scene track's own half of the frozen contract: window.mudiiiScene.
|
|
868
|
+
// three.js is ~800 KB and can fail to vendor-load independently of
|
|
869
|
+
// anything this page does, so every call is guarded the same way — a
|
|
870
|
+
// missing or throwing scene must never take the map panel, the HUD, the
|
|
871
|
+
// deck or the chat down with it.
|
|
872
|
+
function callScene(method) {
|
|
873
|
+
const scene = window.mudiiiScene;
|
|
874
|
+
if (!scene || typeof scene[method] !== "function") return;
|
|
875
|
+
try {
|
|
876
|
+
return scene[method].apply(scene, Array.prototype.slice.call(arguments, 1));
|
|
877
|
+
} catch (err) {
|
|
878
|
+
setSceneStatus("the 3D scene hit an error and stopped updating \\u2014 the rest of the page still works.");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function applyTickResult(result) {
|
|
883
|
+
if (!result) return;
|
|
884
|
+
if (result.agents) agentsById = result.agents;
|
|
885
|
+
if (result.items) itemsById = result.items;
|
|
886
|
+
callScene("applyTick", { agents: result.agents, items: result.items, ecology: result.ecology });
|
|
887
|
+
camera = nextCameraSelection(camera, agentsList(), result.ecology || []);
|
|
888
|
+
callScene("setCamera", camera);
|
|
889
|
+
if (camera.status) setSceneStatus(camera.status);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function runOneTick() {
|
|
893
|
+
return serializeTick(async function () {
|
|
894
|
+
if (!session) return null;
|
|
895
|
+
globalTurn += 1;
|
|
896
|
+
const result = await session.tick(globalTurn);
|
|
897
|
+
applyTickResult(result);
|
|
898
|
+
renderAll();
|
|
899
|
+
return result;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function ensureTicker() {
|
|
904
|
+
if (ticker) return ticker;
|
|
905
|
+
ticker = createTicker({
|
|
906
|
+
onTick: runOneTick,
|
|
907
|
+
onRender: function (state) {
|
|
908
|
+
const playBtn = el("autoToggle");
|
|
909
|
+
playBtn.setAttribute("aria-pressed", state.playing ? "true" : "false");
|
|
910
|
+
playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
|
|
911
|
+
},
|
|
912
|
+
hasNext: hasNext,
|
|
913
|
+
wait: liveWait,
|
|
914
|
+
});
|
|
915
|
+
return ticker;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---- chat ------------------------------------------------------------
|
|
919
|
+
function appendChat(cls, text) { appendLogLine(el("chatLog"), cls, text, { clip: true }); }
|
|
920
|
+
|
|
921
|
+
function sendCommand(line) {
|
|
922
|
+
appendChat("u", line);
|
|
923
|
+
if (!session) { appendChat("a", "no session is open yet \\u2014 reset to start one."); return Promise.resolve(); }
|
|
924
|
+
return serializeTick(function () { return tmct.turn(line); }).then(function (res) {
|
|
925
|
+
appendChat("a", res.answer);
|
|
926
|
+
renderAll();
|
|
927
|
+
return res;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
el("chatForm").addEventListener("submit", function (e) {
|
|
932
|
+
e.preventDefault();
|
|
933
|
+
const input = el("chatInput");
|
|
934
|
+
const line = input.value.trim();
|
|
935
|
+
if (!line) return;
|
|
936
|
+
input.value = "";
|
|
937
|
+
sendCommand(line);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
const chatLog = el("chatLog");
|
|
941
|
+
chatLog.addEventListener("click", function (e) {
|
|
942
|
+
const line = e.target.closest(".clipped");
|
|
943
|
+
if (line) { el("chatLogPopupText").textContent = line.textContent; el("chatLogPopup").hidden = false; }
|
|
944
|
+
});
|
|
945
|
+
el("chatLogPopupClose").addEventListener("click", function () { el("chatLogPopup").hidden = true; });
|
|
946
|
+
|
|
947
|
+
// ---- the pill rail and its typeahead ----------------------------------
|
|
948
|
+
function renderChatPills() {
|
|
949
|
+
const pills = [{ command: "look", label: "look" }];
|
|
950
|
+
for (const id of Object.keys(agentsById)) pills.push({ command: "@" + id + " look", label: "@" + id + " look" });
|
|
951
|
+
livePills = pills;
|
|
952
|
+
el("chatPills").innerHTML = pills.map(function (p) {
|
|
953
|
+
return '<button type="button" class="pill" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
|
|
954
|
+
}).join("");
|
|
955
|
+
const buttons = el("chatPills").querySelectorAll(".pill");
|
|
956
|
+
for (let i = 0; i < buttons.length; i += 1) {
|
|
957
|
+
buttons[i].addEventListener("click", function (e) { sendCommand(e.currentTarget.getAttribute("data-command")); });
|
|
958
|
+
}
|
|
959
|
+
if (pillComplete) pillComplete.refresh();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function wirePillComplete() {
|
|
963
|
+
pillComplete = createPillComplete({
|
|
964
|
+
input: el("chatInput"),
|
|
965
|
+
ghostEl: el("chatInput-pc-ghost"),
|
|
966
|
+
statusEl: el("chatInput-pc-status"),
|
|
967
|
+
railEl: el("chatPills"),
|
|
968
|
+
getCandidates: function () { return livePills; },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ---- food placement ----------------------------------------------------
|
|
973
|
+
el("foodPill").addEventListener("click", function () {
|
|
974
|
+
foodArmed = !foodArmed;
|
|
975
|
+
el("foodPill").setAttribute("aria-pressed", foodArmed ? "true" : "false");
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// The scene track's own script calls this on a raycast hit against the
|
|
979
|
+
// ground plane (see this module's header on the two scripts' split). Kept
|
|
980
|
+
// as a plain global rather than a spliced closure binding, because the
|
|
981
|
+
// scene script is embedded as its OWN standalone <script> tag and shares
|
|
982
|
+
// no lexical scope with this one.
|
|
983
|
+
//
|
|
984
|
+
// Routed through tmct.turn (sendCommand), never session.placeFood
|
|
985
|
+
// directly: a typed "put food at cell-3-4" and this click must write the
|
|
986
|
+
// same facts through the same provenance stamp, so "who put that there?"
|
|
987
|
+
// grounds and answers identically either way, and the click's own result
|
|
988
|
+
// lands in the chat log instead of vanishing silently. A blocked cell is
|
|
989
|
+
// still refused entirely client-side — nothing is written, and the food
|
|
990
|
+
// pill stays armed for another try.
|
|
991
|
+
window.mudiiiHandleSceneClick = function (cellId) {
|
|
992
|
+
if (!foodArmed || !session) return;
|
|
993
|
+
const reason = blockedCellReason(cellId, props, agentsList());
|
|
994
|
+
if (reason) { setSceneStatus(reason); return; }
|
|
995
|
+
sendCommand("put food at " + cellId).then(function () {
|
|
996
|
+
foodArmed = false;
|
|
997
|
+
el("foodPill").setAttribute("aria-pressed", "false");
|
|
998
|
+
});
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// ---- the HUD row --------------------------------------------------------
|
|
1002
|
+
function renderHudRow() {
|
|
1003
|
+
const ids = Object.keys(agentsById).sort();
|
|
1004
|
+
const row = el("hudRow");
|
|
1005
|
+
if (row.children.length !== ids.length) {
|
|
1006
|
+
row.innerHTML = ids.map(function (id, i) { return agentCardMarkup(String(i)); }).join("");
|
|
1007
|
+
}
|
|
1008
|
+
const cards = row.querySelectorAll(".hud-card");
|
|
1009
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
1010
|
+
const id = ids[i];
|
|
1011
|
+
const card = cards[i];
|
|
1012
|
+
if (!card) continue;
|
|
1013
|
+
card.setAttribute("data-agent", id);
|
|
1014
|
+
const fields = hudCardFieldsFor(Object.assign({ id: id }, agentsById[id]), DATA.mudiiiConfig);
|
|
1015
|
+
card.querySelector(".hud-card-id").textContent = fields.id;
|
|
1016
|
+
card.querySelector(".hud-card-role").textContent = fields.role || "";
|
|
1017
|
+
card.querySelector(".hud-meter-fill").style.width = (fields.massPct === null ? 0 : fields.massPct) + "%";
|
|
1018
|
+
card.querySelector(".hud-goal").textContent = fields.goal;
|
|
1019
|
+
card.querySelector(".hud-plan").textContent = "plan: " + fields.planText;
|
|
1020
|
+
card.querySelector(".hud-belief").textContent = fields.beliefEntries.length
|
|
1021
|
+
? "believes: " + fields.beliefEntries.map(function (entry) {
|
|
1022
|
+
return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
|
|
1023
|
+
}).join(" \\u00b7 ")
|
|
1024
|
+
: "";
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ---- the top-down map panel ---------------------------------------------
|
|
1029
|
+
function renderMapPanel() {
|
|
1030
|
+
const dots = mapDotsFor(agentsList(), itemsList(), DATA.gridSize);
|
|
1031
|
+
el("mapPanelBoard").innerHTML = dots.map(function (d) {
|
|
1032
|
+
return '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
|
|
1033
|
+
}).join("");
|
|
1034
|
+
el("mapPanelTurn").textContent = "turn " + globalTurn;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ---- follow dropdown and camera mode ------------------------------------
|
|
1038
|
+
function renderAgentSelect() {
|
|
1039
|
+
const select = el("agentSelect");
|
|
1040
|
+
const ids = Object.keys(agentsById).sort();
|
|
1041
|
+
const current = camera.selectedId;
|
|
1042
|
+
select.innerHTML = ids.map(function (id) {
|
|
1043
|
+
return '<option value="' + esc(id) + '"' + (id === current ? " selected" : "") + '>' + esc(id) + "</option>";
|
|
1044
|
+
}).join("");
|
|
1045
|
+
}
|
|
1046
|
+
el("agentSelect").addEventListener("change", function () {
|
|
1047
|
+
const id = el("agentSelect").value || null;
|
|
1048
|
+
camera = { mode: camera.mode, selectedId: id, status: null };
|
|
1049
|
+
callScene("setCamera", camera);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
function renderCameraButtons() {
|
|
1053
|
+
const buttons = el("cameraMode").querySelectorAll("button");
|
|
1054
|
+
for (let i = 0; i < buttons.length; i += 1) {
|
|
1055
|
+
buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-mode") === camera.mode ? "true" : "false");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
el("cameraMode").addEventListener("click", function (e) {
|
|
1059
|
+
const btn = e.target.closest("button[data-mode]");
|
|
1060
|
+
if (!btn) return;
|
|
1061
|
+
camera = { mode: btn.getAttribute("data-mode"), selectedId: camera.selectedId, status: null };
|
|
1062
|
+
renderCameraButtons();
|
|
1063
|
+
callScene("setCamera", camera);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// ---- the control deck ----------------------------------------------------
|
|
1067
|
+
function chosenFoxCount() { return DATA.playerCounts[Number(el("playerCountSlider").value)] || DATA.defaultPlayerCount; }
|
|
1068
|
+
function showFoxCount(n) {
|
|
1069
|
+
el("playerCountValue").textContent = String(n);
|
|
1070
|
+
el("playerCountSlider").setAttribute("aria-valuetext", n + (n === 1 ? " fox" : " foxes"));
|
|
1071
|
+
}
|
|
1072
|
+
function chosenGoblinCount() { const n = Number(el("npcCountSlider").value); return Number.isFinite(n) ? n : DATA.defaultNpcCount; }
|
|
1073
|
+
function showGoblinCount(n) {
|
|
1074
|
+
el("npcCountValue").textContent = String(n);
|
|
1075
|
+
el("npcCountSlider").setAttribute("aria-valuetext", n + (n === 1 ? " goblin" : " goblins"));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function wireDeck() {
|
|
1079
|
+
el("autoToggle").addEventListener("click", function () {
|
|
1080
|
+
if (!session) return;
|
|
1081
|
+
autoOn = !autoOn;
|
|
1082
|
+
if (autoOn) ensureTicker().play(); else ensureTicker().pause();
|
|
1083
|
+
});
|
|
1084
|
+
el("delaySlider").addEventListener("input", function () {
|
|
1085
|
+
delayMs = Number(el("delaySlider").value);
|
|
1086
|
+
el("delayValue").textContent = delayMs + "ms";
|
|
1087
|
+
});
|
|
1088
|
+
el("maxTurnsSlider").addEventListener("input", function () {
|
|
1089
|
+
maxTurns = Number(el("maxTurnsSlider").value);
|
|
1090
|
+
el("maxTurnsValue").textContent = String(maxTurns);
|
|
1091
|
+
});
|
|
1092
|
+
el("playerCountSlider").addEventListener("input", function () { showFoxCount(chosenFoxCount()); });
|
|
1093
|
+
el("playerCountSlider").addEventListener("change", function () { boot(); });
|
|
1094
|
+
el("npcCountSlider").addEventListener("input", function () { showGoblinCount(chosenGoblinCount()); });
|
|
1095
|
+
el("npcCountSlider").addEventListener("change", function () { boot(); });
|
|
1096
|
+
el("resetBtn").addEventListener("click", function () { boot(); });
|
|
1097
|
+
const scenarioSelect = el("scenarioSelect");
|
|
1098
|
+
if (scenarioSelect) {
|
|
1099
|
+
scenarioSelect.addEventListener("change", function () {
|
|
1100
|
+
const picked = Number(scenarioSelect.value);
|
|
1101
|
+
if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
|
|
1102
|
+
scenarioIndex = picked;
|
|
1103
|
+
boot();
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
el("editModeBtn").addEventListener("click", function () {
|
|
1107
|
+
if (editing) exitEditMode(); else enterEditMode();
|
|
1108
|
+
});
|
|
1109
|
+
const infoBtn = el("deckInfoBtn");
|
|
1110
|
+
const infoPopup = el("deckInfoPopup");
|
|
1111
|
+
infoBtn.addEventListener("click", function () {
|
|
1112
|
+
const opening = infoPopup.hidden;
|
|
1113
|
+
infoPopup.hidden = !opening;
|
|
1114
|
+
infoBtn.setAttribute("aria-expanded", opening ? "true" : "false");
|
|
1115
|
+
});
|
|
1116
|
+
el("deckInfoClose").addEventListener("click", function () {
|
|
1117
|
+
infoPopup.hidden = true;
|
|
1118
|
+
infoBtn.setAttribute("aria-expanded", "false");
|
|
1119
|
+
});
|
|
1120
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !infoPopup.hidden) { infoPopup.hidden = true; infoBtn.setAttribute("aria-expanded", "false"); } });
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---- edit mode -------------------------------------------------------
|
|
1124
|
+
// renderMudEditorText/session.applyEdit round-trip mgx:model and
|
|
1125
|
+
// mgx:rotation unchanged (mud-editor.mjs, shared with mud.html); the side
|
|
1126
|
+
// panel here is a plain "who/what stands where" list rather than
|
|
1127
|
+
// mud.html's burrow-survey SVG, since a town square is a grid, not a room
|
|
1128
|
+
// graph.
|
|
1129
|
+
function worldOnlyRows(rows) { return rowsForWorld(rows, scenario().worldPayload.name); }
|
|
1130
|
+
let editRows = [];
|
|
1131
|
+
|
|
1132
|
+
function renderEditPlacements() {
|
|
1133
|
+
const placements = {};
|
|
1134
|
+
for (const row of editRows) {
|
|
1135
|
+
if (row.predicate !== "mgx:currently-in") continue;
|
|
1136
|
+
placements[row.subject] = row.object;
|
|
1137
|
+
}
|
|
1138
|
+
const subjects = Object.keys(placements).sort();
|
|
1139
|
+
el("editPlacements").innerHTML = subjects.length
|
|
1140
|
+
? subjects.map(function (s) {
|
|
1141
|
+
return '<div class="edit-placement-row"><span class="mono">' + esc(s) + '</span><span>' + esc(placements[s]) + "</span></div>";
|
|
1142
|
+
}).join("")
|
|
1143
|
+
: '<span class="edit-empty">nothing placed yet</span>';
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function enterEditMode() {
|
|
1147
|
+
if (!session) return;
|
|
1148
|
+
editing = true;
|
|
1149
|
+
autoOn = false;
|
|
1150
|
+
if (ticker) ticker.pause();
|
|
1151
|
+
document.body.classList.add("editing");
|
|
1152
|
+
el("editModeBtn").textContent = "back to playing";
|
|
1153
|
+
el("editModeBtn").setAttribute("aria-pressed", "true");
|
|
1154
|
+
const snap = await session.snapshot();
|
|
1155
|
+
editRows = worldOnlyRows(snap.rows);
|
|
1156
|
+
el("editorText").value = renderMudEditorText(editRows, gridWorldEditorState(snap.state));
|
|
1157
|
+
el("editorStatus").className = "edit-status";
|
|
1158
|
+
el("editorStatus").textContent = "";
|
|
1159
|
+
el("editorPills").innerHTML = "";
|
|
1160
|
+
renderEditPlacements();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function exitEditMode() {
|
|
1164
|
+
editing = false;
|
|
1165
|
+
document.body.classList.remove("editing");
|
|
1166
|
+
el("editModeBtn").textContent = "edit";
|
|
1167
|
+
el("editModeBtn").setAttribute("aria-pressed", "false");
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
let syncTimer = null;
|
|
1171
|
+
function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
|
|
1172
|
+
el("editorText").addEventListener("input", scheduleSync);
|
|
1173
|
+
|
|
1174
|
+
async function applyEditorText() {
|
|
1175
|
+
if (!session) return;
|
|
1176
|
+
const status = el("editorStatus");
|
|
1177
|
+
status.className = "edit-status pending";
|
|
1178
|
+
status.textContent = "reading the square\\u2026";
|
|
1179
|
+
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
1180
|
+
const snap = await session.snapshot();
|
|
1181
|
+
editRows = worldOnlyRows(snap.rows);
|
|
1182
|
+
if (result && result.unrecognized && result.unrecognized.length) {
|
|
1183
|
+
status.className = "edit-status pending";
|
|
1184
|
+
status.textContent = result.unrecognized.length + " line" + (result.unrecognized.length === 1 ? "" : "s")
|
|
1185
|
+
+ " not understood yet \\u2014 nothing is retracted until they are.";
|
|
1186
|
+
} else {
|
|
1187
|
+
status.className = "edit-status ok";
|
|
1188
|
+
status.textContent = (result && (result.added || result.removed))
|
|
1189
|
+
? "synced \\u2014 " + result.added + " fact(s) written, " + result.removed + " retracted."
|
|
1190
|
+
: "synced \\u2014 no change.";
|
|
1191
|
+
}
|
|
1192
|
+
renderEditPlacements();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ---- booting ---------------------------------------------------------
|
|
1196
|
+
// Nothing plays on load: booting draws the opening state and stops there.
|
|
1197
|
+
// Ticking waits for the deck's own play control, exactly like mud.html.
|
|
1198
|
+
let bootSeq = 0;
|
|
1199
|
+
async function boot() {
|
|
1200
|
+
const seq = bootSeq += 1;
|
|
1201
|
+
autoOn = false;
|
|
1202
|
+
if (ticker) { ticker.pause(); ticker = null; }
|
|
1203
|
+
globalTurn = 0;
|
|
1204
|
+
tickQueue = createSerialQueue();
|
|
1205
|
+
camera = { mode: "follow", selectedId: null, status: null };
|
|
1206
|
+
const s = scenario();
|
|
1207
|
+
const foxes = pickRoster(rosterOf(s, "predator"), chosenFoxCount());
|
|
1208
|
+
const goblins = pickRoster(rosterOf(s, "prey"), chosenGoblinCount());
|
|
1209
|
+
cast = foxes.concat(goblins);
|
|
1210
|
+
showFoxCount(foxes.length);
|
|
1211
|
+
showGoblinCount(goblins.length);
|
|
1212
|
+
props = propPlacementsFrom((s.worldPayload && s.worldPayload.facts) || [], DATA.assetManifest);
|
|
1213
|
+
const opened = await window.tmct.open(s.worldPayload, { agents: cast, epoch: 0 });
|
|
1214
|
+
if (seq !== bootSeq) return;
|
|
1215
|
+
session = opened;
|
|
1216
|
+
agentsById = {};
|
|
1217
|
+
itemsById = {};
|
|
1218
|
+
el("chatInput").disabled = false;
|
|
1219
|
+
callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
|
|
1220
|
+
|
|
1221
|
+
// The opening board, drawn through the very path a tick takes. Without
|
|
1222
|
+
// this the page's first sight of where anything stands is the first tick,
|
|
1223
|
+
// so every mesh sits unplaced and the map panel is blank until the visitor
|
|
1224
|
+
// presses play. The engine mints the cast at seeded cells, so the ids and
|
|
1225
|
+
// the cells both come back from it rather than being guessed here.
|
|
1226
|
+
const opening = await session.board();
|
|
1227
|
+
if (seq !== bootSeq) return;
|
|
1228
|
+
globalTurn = opening.turn || 0;
|
|
1229
|
+
camera.selectedId = Object.keys(opening.agents || {}).sort()[0] || null;
|
|
1230
|
+
applyTickResult(opening);
|
|
1231
|
+
renderAll();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function renderAll() {
|
|
1235
|
+
renderHudRow();
|
|
1236
|
+
renderMapPanel();
|
|
1237
|
+
renderAgentSelect();
|
|
1238
|
+
renderCameraButtons();
|
|
1239
|
+
renderChatPills();
|
|
1240
|
+
el("globalTurnCount").textContent = "turns: " + globalTurn;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
wireDeck();
|
|
1244
|
+
wirePillComplete();
|
|
1245
|
+
boot();
|
|
1246
|
+
})();`;
|
|
1247
|
+
}
|