@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -9
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +8 -8
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/sprites/src/sprite-facts.jsonl +401 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +49 -9
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/adapters/memory/core.mjs +20 -0
- package/src/domain/ask-vocab.mjs +71 -0
- package/src/domain/ask.mjs +168 -0
- package/src/domain/game-config.mjs +11 -0
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +18 -0
- package/src/domain/mud-facts.mjs +15 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/router/drive.mjs +35 -9
- package/src/domain/router/registry.mjs +24 -4
- package/src/domain/router/resolver.mjs +102 -40
- package/src/domain/scene-compose.mjs +117 -0
- package/src/domain/spider-fly-world.mjs +37 -1
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/domain/sprite-request.mjs +156 -0
- package/src/domain/sprite-templates.mjs +169 -20
- package/src/services/adventure-editor.mjs +8 -14
- package/src/services/adventure-viz.mjs +209 -157
- package/src/services/adventure.mjs +526 -391
- package/src/services/chat-page-viz.mjs +69 -25
- package/src/services/chat.mjs +200 -51
- package/src/services/code-explorer-viz.mjs +102 -62
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +68 -82
- package/src/services/ledger-viz.mjs +136 -67
- package/src/services/memory-panel-viz.mjs +62 -0
- package/src/services/mud-editor.mjs +10 -15
- package/src/services/mud-turn.mjs +6 -6
- package/src/services/mud-viz.mjs +1016 -208
- package/src/services/p2p-room.mjs +90 -23
- package/src/services/plan-pddl.mjs +3 -1
- package/src/services/plan-viz.mjs +123 -64
- package/src/services/research-viz.mjs +160 -108
- package/src/services/spider-fly-turn.mjs +15 -23
- package/src/services/spider-fly-viz.mjs +146 -161
- package/src/services/spider-fly.mjs +69 -11
- package/src/services/sprite-catalog-viz.mjs +414 -240
- package/src/services/viz-boot.mjs +71 -0
- package/src/services/viz-room-graph.mjs +203 -0
- package/src/services/viz-theme.mjs +90 -1
- package/src/services/viz-ticker.mjs +22 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
- package/src/surfaces/web/chat-browser-entry.mjs +30 -105
- package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
- package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
- package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
- package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
- package/src/surfaces/web/memory-stats.mjs +11 -0
- package/src/surfaces/web/mud-browser-entry.mjs +71 -29
- package/src/surfaces/web/plan-browser-entry.mjs +22 -40
- package/src/surfaces/web/research-browser-entry.mjs +26 -41
- package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
- package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
- package/src/surfaces/web/turn-session.mjs +120 -0
- package/src/tools/definitions.mjs +30 -0
- package/src/tools/handlers/index.mjs +6 -3
- package/src/tools/handlers/kit.mjs +19 -2
- package/src/tools/handlers/tmct-ask.mjs +11 -6
- package/src/tools/handlers/tmct-ingest.mjs +5 -1
- package/src/tools/handlers/tmct-related.mjs +4 -4
- package/src/tools/handlers/tmct-sprite.mjs +147 -0
- package/src/tools/memory-fallthrough.mjs +9 -2
- package/src/tools/server.mjs +25 -1
package/src/services/mud-viz.mjs
CHANGED
|
@@ -57,30 +57,40 @@
|
|
|
57
57
|
// The burrow survey is ONE svg routine (burrowSvg over burrowGraph) drawn
|
|
58
58
|
// twice: the omniscient board in the top row, and each pane's own
|
|
59
59
|
// visited-only "known ground". Same layout, same treatment, one code path —
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
60
|
+
// burrowGraph/burrowSvg are thin wrappers over viz-room-graph.mjs's shared
|
|
61
|
+
// directedGridLayout/roomGraphSvg (the same pair adventure-viz.mjs draws its
|
|
62
|
+
// own manor board from), passing the `root`/`nudgeCollisions`/`turf` options a
|
|
63
|
+
// burrow needs and a manor does not (a mud character can dig down AND south
|
|
64
|
+
// out of one room, and both land on the same grid cell otherwise).
|
|
64
65
|
//
|
|
65
66
|
// ONE ticker instance per pane (viz-ticker.mjs's createTicker), so
|
|
66
67
|
// each can play/step independently, plus the deck's own "play" control that
|
|
67
68
|
// calls .play()/.pause() on both pane tickers at once. Nothing plays until
|
|
68
69
|
// that control is clicked. Every tick, from ANY pane, is funneled through one
|
|
69
|
-
// shared async queue (serializeTick,
|
|
70
|
-
//
|
|
71
|
-
// mud-browser-entry.mjs's own
|
|
72
|
-
// responsibility, and this is where that
|
|
73
|
-
// queue is also what makes the deck's
|
|
74
|
-
// increments in the exact order turns
|
|
75
|
-
// between panes.
|
|
76
|
-
import {
|
|
77
|
-
|
|
70
|
+
// shared async queue (serializeTick, wrapping viz-ticker.mjs's own
|
|
71
|
+
// createSerialQueue) so two characters' turns can never interleave their
|
|
72
|
+
// reads/writes of the one shared memoryDir — mud-browser-entry.mjs's own
|
|
73
|
+
// header names this as the caller's responsibility, and this is where that
|
|
74
|
+
// responsibility is discharged. The queue is also what makes the deck's
|
|
75
|
+
// GLOBAL turn counter well-defined: it increments in the exact order turns
|
|
76
|
+
// actually executed, never a race between panes.
|
|
77
|
+
import {
|
|
78
|
+
THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
|
|
79
|
+
wordBeforeCursor, rowsForWorld, appendLogLine,
|
|
80
|
+
} from "./viz-theme.mjs";
|
|
81
|
+
import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
|
|
82
|
+
import { directedGridLayout, roomGraphSvg, levelsOf, EXIT_DELTA } from "./viz-room-graph.mjs";
|
|
78
83
|
import {
|
|
79
84
|
roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
|
|
80
85
|
visibleRoomOf, roomKindForRoom, allRoomIds,
|
|
81
86
|
} from "./adventure-viz.mjs";
|
|
82
|
-
import { renderMudEditorText
|
|
87
|
+
import { renderMudEditorText } from "./mud-editor.mjs";
|
|
88
|
+
import {
|
|
89
|
+
wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
|
|
90
|
+
} from "./chat-page-viz.mjs";
|
|
83
91
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
92
|
+
import { fnv1a32 } from "../domain/hash.mjs";
|
|
93
|
+
import { predatorSubjects } from "../domain/mud-facts.mjs";
|
|
84
94
|
|
|
85
95
|
const DEFAULT_TITLE = "tmct — the mud";
|
|
86
96
|
// No display-face embedding pipeline exists anywhere in this project yet
|
|
@@ -110,6 +120,7 @@ const DEFAULT_MAX_TURNS = 400;
|
|
|
110
120
|
const MUD_NOTE_LINES = [
|
|
111
121
|
"Burrowing animals share one world here. The players slider picks how many get a window of their own; the npcs slider adds more animals that dig and forage without one. Each one only knows what it has dug up, asked about, or been told. Nobody sees the whole burrow except you, watching from the survey above.",
|
|
112
122
|
`This is a MUD, short for Multi Underground creature Dig. The name nods to MUD, or in its current form MUDII (mudii.co.uk), one of the first multiplayer text games. The dig-your-own-rooms idea came from a skim of Wikipedia's Colossal Cave Adventure article, the game that started the genre.`,
|
|
123
|
+
"Share the burrow and a second digger joins from another machine, browser to browser, with no server in the middle. Each animal is claimed by one node, one hand on one lemming, and everything either of you digs, eats or learns lands in the same graph.",
|
|
113
124
|
];
|
|
114
125
|
|
|
115
126
|
/** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
|
|
@@ -146,22 +157,9 @@ export function carriedItemsFor(rows, state, character) {
|
|
|
146
157
|
* keeps the level unchanged — what tells the survey where the turf line
|
|
147
158
|
* falls. A room unreachable from `root` (should not happen — every dug room
|
|
148
159
|
* writes a two-way exit back) is simply absent from the map rather than
|
|
149
|
-
* guessed at.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const queue = [root];
|
|
153
|
-
while (queue.length) {
|
|
154
|
-
const room = queue.shift();
|
|
155
|
-
const level = levels.get(room);
|
|
156
|
-
for (const [direction, target] of state.exits.get(room) ?? []) {
|
|
157
|
-
if (levels.has(target)) continue;
|
|
158
|
-
const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
|
|
159
|
-
levels.set(target, level + delta);
|
|
160
|
-
queue.push(target);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return levels;
|
|
164
|
-
}
|
|
160
|
+
* guessed at. Re-exported from viz-room-graph.mjs's shared implementation,
|
|
161
|
+
* which this module also splices into its own inline page script. */
|
|
162
|
+
export { levelsOf };
|
|
165
163
|
|
|
166
164
|
/** Which characters currently stand in `room` — the survey's own per-room
|
|
167
165
|
* roster, both for the omniscient board and a pane's own visited-only one.
|
|
@@ -172,62 +170,14 @@ export function charactersInRoom(state, room, characters) {
|
|
|
172
170
|
|
|
173
171
|
/** The rooms in `roomIds` laid out on an integer grid FROM the world's own
|
|
174
172
|
* has-exit-* directions — never a force-directed guess, so a room north of
|
|
175
|
-
* another sits one row above it and a room dug DOWN sits one row below.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
|
|
182
|
-
* every exit from an included room toward one that is not — the direction
|
|
183
|
-
* only, never the excluded room's own name, so a fog-of-war caller can draw
|
|
184
|
-
* "there's a way on" and nothing more. Pure. */
|
|
173
|
+
* another sits one row above it and a room dug DOWN sits one row below. A
|
|
174
|
+
* thin wrapper over viz-room-graph.mjs's shared `directedGridLayout`, always
|
|
175
|
+
* passing `nudgeCollisions: true` — the one thing a burrow needs that a
|
|
176
|
+
* manor does not: a cell already taken nudges right rather than stacking two
|
|
177
|
+
* rooms on one square (dig down and dig south from the same room otherwise
|
|
178
|
+
* collide). Pure. */
|
|
185
179
|
export function burrowGraph(state, roomIds, root = "garden") {
|
|
186
|
-
|
|
187
|
-
const known = new Set(roomIds || []);
|
|
188
|
-
const positions = new Map();
|
|
189
|
-
const taken = new Set();
|
|
190
|
-
const edges = [];
|
|
191
|
-
const edgeKeys = new Set();
|
|
192
|
-
const hints = [];
|
|
193
|
-
const place = (room, x, y) => {
|
|
194
|
-
let px = x;
|
|
195
|
-
for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
|
|
196
|
-
positions.set(room, { x: px, y });
|
|
197
|
-
taken.add(px + "," + y);
|
|
198
|
-
};
|
|
199
|
-
let offsetX = 0;
|
|
200
|
-
for (const start of [root, ...[...known].sort()]) {
|
|
201
|
-
if (!known.has(start) || positions.has(start)) continue;
|
|
202
|
-
place(start, offsetX, 0);
|
|
203
|
-
const queue = [start];
|
|
204
|
-
while (queue.length) {
|
|
205
|
-
const room = queue.shift();
|
|
206
|
-
const pos = positions.get(room);
|
|
207
|
-
const dirs = state.exits.get(room);
|
|
208
|
-
for (const direction of [...(dirs?.keys() ?? [])].sort()) {
|
|
209
|
-
const target = dirs.get(direction);
|
|
210
|
-
if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
|
|
211
|
-
const key = [room, target].sort().join("\0");
|
|
212
|
-
if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
|
|
213
|
-
if (positions.has(target)) continue;
|
|
214
|
-
const d = DELTA[direction] ?? [0, 0];
|
|
215
|
-
place(target, pos.x + d[0], pos.y + d[1]);
|
|
216
|
-
queue.push(target);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
offsetX = Math.max(...[...positions.values()].map((p) => p.x)) + 2;
|
|
220
|
-
}
|
|
221
|
-
const levels = levelsOf(state, root);
|
|
222
|
-
const xs = [...positions.values()].map((p) => p.x);
|
|
223
|
-
const ys = [...positions.values()].map((p) => p.y);
|
|
224
|
-
const minX = Math.min(0, ...xs);
|
|
225
|
-
const minY = Math.min(0, ...ys);
|
|
226
|
-
const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
|
|
227
|
-
const p = positions.get(room);
|
|
228
|
-
return { id: room, x: p.x - minX, y: p.y - minY, level: levels.has(room) ? levels.get(room) : null };
|
|
229
|
-
});
|
|
230
|
-
return { nodes, edges, hints };
|
|
180
|
+
return directedGridLayout(state, roomIds, { root, nudgeCollisions: true });
|
|
231
181
|
}
|
|
232
182
|
|
|
233
183
|
/** The directions `here` can still be dug in: every direction the room's kind
|
|
@@ -269,6 +219,106 @@ export function isCreature(state, subject) {
|
|
|
269
219
|
return state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
270
220
|
}
|
|
271
221
|
|
|
222
|
+
// ---- the shared burrow: reading the P2P layer's own facts back ---------------
|
|
223
|
+
// All four readers below work on plain fact rows and nothing else, so the page
|
|
224
|
+
// draws a claim, a wave and a node name identically whether the world is shared
|
|
225
|
+
// with three machines or with none. Pure and `.toString()`-splice safe.
|
|
226
|
+
|
|
227
|
+
/** Every assertion time a provenance string carries, in milliseconds. A tag is
|
|
228
|
+
* `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
|
|
229
|
+
* by " | " — a peer's relabelled copy of a tag keeps the time the fact was
|
|
230
|
+
* actually asserted, so reading them all back is what lets a claim be settled
|
|
231
|
+
* by its oldest and a wave by its newest. Pure, self-contained. */
|
|
232
|
+
export function provenanceStamps(provenance) {
|
|
233
|
+
const stamps = [];
|
|
234
|
+
const text = String(provenance || "");
|
|
235
|
+
const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
|
|
236
|
+
let found = pattern.exec(text);
|
|
237
|
+
while (found !== null) {
|
|
238
|
+
const at = Date.parse(found[1]);
|
|
239
|
+
if (!Number.isNaN(at)) stamps.push(at);
|
|
240
|
+
found = pattern.exec(text);
|
|
241
|
+
}
|
|
242
|
+
return stamps;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A peer id as the fact store actually holds it. `appendFacts` normalizes a
|
|
246
|
+
* term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
|
|
247
|
+
* claim is written with reads back as the bare id — a comparison against a
|
|
248
|
+
* raw peer id has to normalize the same way or it never matches. Pure,
|
|
249
|
+
* self-contained. */
|
|
250
|
+
export function peerTerm(peerId) {
|
|
251
|
+
return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Which peer plays `character`, or null when nobody has claimed it. Claims are
|
|
255
|
+
* add-only and never retracted, so two peers claiming the same animal both
|
|
256
|
+
* write and every page settles it the same way: the OLDEST claim wins, with
|
|
257
|
+
* the lower peer term breaking a dead heat so two machines reading the same
|
|
258
|
+
* rows can never disagree. Pure, self-contained (provenanceStamps is spliced
|
|
259
|
+
* alongside it). */
|
|
260
|
+
export function claimantOf(rows, character) {
|
|
261
|
+
let owner = null;
|
|
262
|
+
let ownerAt = Infinity;
|
|
263
|
+
for (const row of rows || []) {
|
|
264
|
+
if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
|
|
265
|
+
let at = Infinity;
|
|
266
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
|
|
267
|
+
if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
|
|
268
|
+
ownerAt = at;
|
|
269
|
+
owner = String(row.object);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return owner;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Which characters are waving right now — a read-time recency question over
|
|
276
|
+
* the newest tag on each `mgx:waved` fact, never stored state and never a
|
|
277
|
+
* retraction. A wave older than the window simply stops being read as one.
|
|
278
|
+
* Pure, self-contained. */
|
|
279
|
+
export function wavingCharacters(rows, nowMs, windowMs = 8000) {
|
|
280
|
+
const waving = [];
|
|
281
|
+
for (const row of rows || []) {
|
|
282
|
+
if (row.predicate !== "mgx:waved") continue;
|
|
283
|
+
let newest = null;
|
|
284
|
+
for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
|
|
285
|
+
if (newest === null) continue;
|
|
286
|
+
const age = nowMs - newest;
|
|
287
|
+
if (age < 0 || age > windowMs) continue;
|
|
288
|
+
if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
|
|
289
|
+
}
|
|
290
|
+
return waving;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The offer blob inside whatever somebody actually pasted: the `offer`
|
|
294
|
+
* parameter of a whole invite link, or the bare blob when that is what
|
|
295
|
+
* arrived. Both are what people really paste, and telling them apart by
|
|
296
|
+
* looking is cheaper than asking them to. Pure, self-contained. */
|
|
297
|
+
export function offerBlobIn(pasted) {
|
|
298
|
+
const text = String(pasted || "").trim();
|
|
299
|
+
const marker = text.indexOf("offer=");
|
|
300
|
+
if (marker === -1) return text;
|
|
301
|
+
const rest = text.slice(marker + "offer=".length);
|
|
302
|
+
const end = rest.search(/[&#\s]/);
|
|
303
|
+
return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
|
|
307
|
+
* falling back to a shortened peer id. A label never waits for a name to
|
|
308
|
+
* arrive. Pure, self-contained. */
|
|
309
|
+
export function nodeNameFor(rows, peerId) {
|
|
310
|
+
const want = peerTerm(peerId);
|
|
311
|
+
let name = null;
|
|
312
|
+
let nameAt = -Infinity;
|
|
313
|
+
for (const row of rows || []) {
|
|
314
|
+
if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
|
|
315
|
+
let at = -Infinity;
|
|
316
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
|
|
317
|
+
if (at >= nameAt) { nameAt = at; name = String(row.object); }
|
|
318
|
+
}
|
|
319
|
+
return name || want.slice(0, 8);
|
|
320
|
+
}
|
|
321
|
+
|
|
272
322
|
/** The self-contained mud.html page. Pure — identical output for identical
|
|
273
323
|
* input; every other piece of state (the live world, chat, ticks) is
|
|
274
324
|
* computed in the browser once the sibling bundle loads.
|
|
@@ -280,7 +330,12 @@ export function isCreature(state, subject) {
|
|
|
280
330
|
* the roster cannot fill draws as many panes as it has animals.
|
|
281
331
|
* `worldPayload` is `{ name, facts, rules, opening }`, read once at build
|
|
282
332
|
* time through the real worlds-pack provider (see this module's own
|
|
283
|
-
* header). `
|
|
333
|
+
* header). `scenarios` is the list of burrows the deck's dropdown offers,
|
|
334
|
+
* `[{ label, worldPayload, characters }]` — each one a whole world with its
|
|
335
|
+
* own roster, so picking one recasts the page over that world's animals
|
|
336
|
+
* rather than reskinning this one. `worldPayload`/`characters` name the
|
|
337
|
+
* scenario the page opens with; a page given no `scenarios` is the
|
|
338
|
+
* one-burrow case and ships no dropdown at all. `spriteTemplates` is the
|
|
284
339
|
* large-tier sprite set (data/sprites-large/*.toml) so every species
|
|
285
340
|
* resolves its own art instead of falling back to the flat animal icon.
|
|
286
341
|
* `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
|
|
@@ -293,13 +348,21 @@ export function renderMudHtml({
|
|
|
293
348
|
title = DEFAULT_TITLE,
|
|
294
349
|
worldPayload,
|
|
295
350
|
characters = [],
|
|
351
|
+
scenarios = [],
|
|
296
352
|
spriteTemplates = [],
|
|
297
353
|
mudConfig = DEFAULT_GAME_CONFIG.mud,
|
|
298
354
|
engineBundleJs = "",
|
|
299
355
|
} = {}) {
|
|
300
|
-
const
|
|
356
|
+
const scenarioList = scenarios.length
|
|
357
|
+
? scenarios
|
|
358
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload, characters }];
|
|
359
|
+
const opening = scenarioList[0];
|
|
360
|
+
const openingCount = Math.max(
|
|
361
|
+
1,
|
|
362
|
+
Math.min(DEFAULT_PLAYER_COUNT, (opening.characters || []).length || DEFAULT_PLAYER_COUNT),
|
|
363
|
+
);
|
|
301
364
|
const pageData = embedJson({
|
|
302
|
-
|
|
365
|
+
scenarios: scenarioList, spriteTemplates, mudConfig,
|
|
303
366
|
paneSlots: PANE_SLOTS,
|
|
304
367
|
playerCounts: PLAYER_COUNTS,
|
|
305
368
|
defaultPlayerCount: DEFAULT_PLAYER_COUNT,
|
|
@@ -333,7 +396,14 @@ ${MUD_STYLE}
|
|
|
333
396
|
<div class="deck-controls">
|
|
334
397
|
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
335
398
|
<button type="button" id="resetBtn">reset</button>
|
|
399
|
+
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
|
|
400
|
+
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
401
|
+
</select>` : ""}
|
|
336
402
|
<button type="button" id="editModeBtn" aria-pressed="false">edit</button>
|
|
403
|
+
<button type="button" id="shareBtn">share</button>
|
|
404
|
+
<button type="button" id="joinOpenBtn">join</button>
|
|
405
|
+
<button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
|
|
406
|
+
title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
|
|
337
407
|
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
338
408
|
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
339
409
|
</div>
|
|
@@ -365,8 +435,7 @@ ${MUD_STYLE}
|
|
|
365
435
|
</label>
|
|
366
436
|
</div>
|
|
367
437
|
<div class="deck-info-popup mud-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
|
|
368
|
-
|
|
369
|
-
<p>${escapeHtml(MUD_NOTE_LINES[1])}</p>
|
|
438
|
+
${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
370
439
|
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
371
440
|
</div>
|
|
372
441
|
</section>
|
|
@@ -379,6 +448,61 @@ ${MUD_STYLE}
|
|
|
379
448
|
<div class="world-map-key" id="worldMapKey"></div>
|
|
380
449
|
</section>
|
|
381
450
|
</div>
|
|
451
|
+
<section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
|
|
452
|
+
<div class="net-head">
|
|
453
|
+
<span class="net-title">the shared burrow</span>
|
|
454
|
+
<span class="net-state" id="wireState" data-tone="idle"><b id="wireStateWord">not shared</b><span id="wireStateNote">this browser holds the only copy of this burrow.</span></span>
|
|
455
|
+
<button type="button" class="net-close" id="netPanelClose" aria-label="close">×</button>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="net-fields">
|
|
458
|
+
<label class="net-field">this node
|
|
459
|
+
<input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
|
|
460
|
+
</label>
|
|
461
|
+
<label class="net-field">this burrow
|
|
462
|
+
<input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
|
|
463
|
+
</label>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="net-block" id="sharePanel" hidden>
|
|
466
|
+
<p class="net-hint">Each link invites one person. Share again for the next.</p>
|
|
467
|
+
<textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="net-block" id="invitePanel" hidden>
|
|
470
|
+
<label class="net-label" for="inviteBox">paste the invite you were sent</label>
|
|
471
|
+
<textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
|
|
472
|
+
<button type="button" id="inviteBtn">make my reply</button>
|
|
473
|
+
<p class="net-problem" id="inviteProblem" role="alert" hidden></p>
|
|
474
|
+
</div>
|
|
475
|
+
<div class="net-block" id="replyPanel">
|
|
476
|
+
<label class="net-label" for="replyBox">paste their reply here</label>
|
|
477
|
+
<textarea id="replyBox" rows="2" spellcheck="false"></textarea>
|
|
478
|
+
<button type="button" id="replyBtn">connect</button>
|
|
479
|
+
<p class="net-problem" id="replyProblem" role="alert" hidden></p>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="net-block" id="answerPanel" hidden>
|
|
482
|
+
<label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
|
|
483
|
+
<textarea id="replyOut" readonly rows="2"></textarea>
|
|
484
|
+
<button type="button" id="copyReplyBtn">copy the reply</button>
|
|
485
|
+
</div>
|
|
486
|
+
<div class="net-block">
|
|
487
|
+
<div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
|
|
488
|
+
<ul class="node-list" id="nodeList"></ul>
|
|
489
|
+
<p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
|
|
490
|
+
</div>
|
|
491
|
+
</section>
|
|
492
|
+
<div class="join-card" id="joinCard" hidden>
|
|
493
|
+
<div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
|
|
494
|
+
<p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
|
|
495
|
+
<h2 class="join-world" id="joinWorld">a shared burrow</h2>
|
|
496
|
+
<p class="join-body" id="joinBody">Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.</p>
|
|
497
|
+
<button type="button" id="joinBtn">create my reply</button>
|
|
498
|
+
<p class="net-problem" id="joinProblem" role="alert" hidden></p>
|
|
499
|
+
<div class="join-reply" id="joinReplyWrap" hidden>
|
|
500
|
+
<textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
|
|
501
|
+
<button type="button" id="joinCopyBtn">copy the reply</button>
|
|
502
|
+
</div>
|
|
503
|
+
<button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
382
506
|
<div class="mud-stage" id="mudStage" data-panes="${openingCount}">
|
|
383
507
|
${paneHtml}
|
|
384
508
|
</div>
|
|
@@ -430,6 +554,7 @@ export function paneMarkup(slot) {
|
|
|
430
554
|
return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
|
|
431
555
|
<div class="pane-head">
|
|
432
556
|
<h2 id="${w}-name">waiting</h2>
|
|
557
|
+
<span class="pane-node" id="${w}-node" hidden></span>
|
|
433
558
|
<span class="mono pane-turn" id="${w}-turn">turn 0</span>
|
|
434
559
|
</div>
|
|
435
560
|
<div class="room-stage">
|
|
@@ -473,7 +598,11 @@ export function paneMarkup(slot) {
|
|
|
473
598
|
</div>
|
|
474
599
|
</div>
|
|
475
600
|
<div class="chat-console">
|
|
476
|
-
<div class="
|
|
601
|
+
<div class="pill-strip">
|
|
602
|
+
<button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
|
|
603
|
+
aria-label="wave">👋 wave</button>
|
|
604
|
+
<div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
|
|
605
|
+
</div>
|
|
477
606
|
<form class="chatask" id="${w}-chatform">
|
|
478
607
|
<span class="prompt mono">tmct></span>
|
|
479
608
|
<input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
|
|
@@ -530,6 +659,12 @@ const MUD_STYLE = `
|
|
|
530
659
|
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
|
|
531
660
|
}
|
|
532
661
|
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
|
|
662
|
+
.deck-select {
|
|
663
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
664
|
+
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
665
|
+
background: rgba(255,255,255,.5); color: var(--mud-ink);
|
|
666
|
+
}
|
|
667
|
+
.deck-select:hover { border-color: var(--burrow-glow); }
|
|
533
668
|
.deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
|
|
534
669
|
.deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
|
|
535
670
|
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
|
|
@@ -885,6 +1020,138 @@ const MUD_STYLE = `
|
|
|
885
1020
|
.edit-stage { grid-template-columns: 1fr; }
|
|
886
1021
|
#editorText { min-height: 18rem; }
|
|
887
1022
|
}
|
|
1023
|
+
|
|
1024
|
+
/* ---- the control panel, in a Lemmings register, on the burrow's own palette ----
|
|
1025
|
+
The 1991 game put everything a player touches in one chunky beveled strip
|
|
1026
|
+
under the level. This deck keeps that shape — one bordered block, a play
|
|
1027
|
+
button that reads as the "go" key, a lit corner readout — but in the same
|
|
1028
|
+
soil/parchment/burrow-glow palette every other panel on the page already
|
|
1029
|
+
uses, so it reads as this world's own control panel rather than a
|
|
1030
|
+
different game's console dropped on top of it. Everything below overrides
|
|
1031
|
+
the parchment deck above it deliberately, so the panel is one block a
|
|
1032
|
+
reader can find rather than a dozen edits. */
|
|
1033
|
+
:root {
|
|
1034
|
+
--lem-face: var(--chalk); --lem-face-hi: #FBF7EF; --lem-face-lo: var(--soil-light);
|
|
1035
|
+
--lem-go: var(--mud-ink); --lem-go-hi: var(--burrow-glow);
|
|
1036
|
+
--lem-alert: #A5432B;
|
|
1037
|
+
--lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
|
|
1038
|
+
--lem-chalk: var(--soil-mid);
|
|
1039
|
+
}
|
|
1040
|
+
.deck-slider, .deck h3 { color: var(--lem-chalk); }
|
|
1041
|
+
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
|
|
1042
|
+
.deck button, .pane-controls button, .net-panel button, .join-inner button {
|
|
1043
|
+
background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
1044
|
+
padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
|
|
1045
|
+
}
|
|
1046
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
|
|
1047
|
+
.net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
|
|
1048
|
+
background: var(--lem-face-hi); border-color: var(--burrow-glow);
|
|
1049
|
+
}
|
|
1050
|
+
.deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
|
|
1051
|
+
.net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
|
|
1052
|
+
background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
|
|
1053
|
+
}
|
|
1054
|
+
.deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
|
|
1055
|
+
.deck-play[aria-pressed="true"] { background: var(--lem-alert) !important; border-color: var(--lem-alert) !important; color: var(--parchment) !important; }
|
|
1056
|
+
/* The corner readout: a lit panel display, in the burrow's own amber rather than DOS-console green. */
|
|
1057
|
+
.deck-turns { background: var(--lem-readout-bg); color: var(--lem-readout);
|
|
1058
|
+
border: 1px solid var(--soil-mid); border-radius: 2px; padding: .1rem .5rem; }
|
|
1059
|
+
.deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
|
|
1060
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1061
|
+
#editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1062
|
+
|
|
1063
|
+
/* ---- whether the burrow is shared, said in one word on the panel ---- */
|
|
1064
|
+
.state-pill { display: inline-flex; align-items: center; gap: .34rem;
|
|
1065
|
+
font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1066
|
+
.state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
|
|
1067
|
+
.state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
|
|
1068
|
+
.state-pill[data-tone="working"] .state-dot { background: #E0912A; }
|
|
1069
|
+
.state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
|
|
1070
|
+
.state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
|
|
1071
|
+
|
|
1072
|
+
.net-panel {
|
|
1073
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
1074
|
+
border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1075
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.3);
|
|
1076
|
+
padding: .7rem .8rem; margin-bottom: 1rem;
|
|
1077
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
|
|
1078
|
+
}
|
|
1079
|
+
.net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
|
1080
|
+
.net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
1081
|
+
.net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
|
|
1082
|
+
.net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1083
|
+
.net-state[data-tone="live"] b { color: var(--lem-go-hi); }
|
|
1084
|
+
.net-state[data-tone="failed"] b { color: #FF8C70; }
|
|
1085
|
+
.net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
|
|
1086
|
+
.net-state span { opacity: .8; }
|
|
1087
|
+
.net-close { margin-left: auto; }
|
|
1088
|
+
.net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
|
|
1089
|
+
.net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
|
|
1090
|
+
.net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
|
|
1091
|
+
.net-field { display: flex; flex-direction: column; gap: .2rem; }
|
|
1092
|
+
.net-panel input[type="text"], .net-panel textarea {
|
|
1093
|
+
font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
|
|
1094
|
+
border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
|
|
1095
|
+
resize: vertical; overflow-wrap: anywhere;
|
|
1096
|
+
}
|
|
1097
|
+
.net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
|
|
1098
|
+
.net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
|
|
1099
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1100
|
+
.net-hint { margin: 0; font-size: .7rem; opacity: .75; }
|
|
1101
|
+
.net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
|
|
1102
|
+
.node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
|
|
1103
|
+
.node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
|
|
1104
|
+
.node-row[data-away="true"] { opacity: .55; }
|
|
1105
|
+
.node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
|
|
1106
|
+
color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
|
|
1107
|
+
align-items: center; justify-content: center; flex: 0 0 auto; }
|
|
1108
|
+
.node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1109
|
+
.node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
|
|
1110
|
+
.node-hand { opacity: 0; }
|
|
1111
|
+
.node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
|
|
1112
|
+
.node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
|
|
1113
|
+
|
|
1114
|
+
.join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
|
|
1115
|
+
background: rgba(8,8,42,.86); padding: 1rem; }
|
|
1116
|
+
/* Setting display at all outranks the browser's own [hidden] rule, so every
|
|
1117
|
+
block here that is hidden by attribute has to say so a second time. */
|
|
1118
|
+
.join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
|
|
1119
|
+
.join-inner { background: var(--soil-deep);
|
|
1120
|
+
color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1121
|
+
box-shadow: 0 10px 26px rgba(0,0,0,.5);
|
|
1122
|
+
padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
|
|
1123
|
+
.join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
|
|
1124
|
+
.join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
|
|
1125
|
+
.join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
|
|
1126
|
+
.join-reply { display: flex; flex-direction: column; gap: .3rem; }
|
|
1127
|
+
.join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
|
|
1128
|
+
background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
|
|
1129
|
+
width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
|
|
1130
|
+
.join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
|
|
1131
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1132
|
+
.join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
|
|
1133
|
+
text-decoration: underline; padding-left: 0 !important; }
|
|
1134
|
+
|
|
1135
|
+
/* ---- a wave, and the node a character is played from ---- */
|
|
1136
|
+
.pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
|
|
1137
|
+
.pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
|
|
1138
|
+
.wave-btn { flex: 0 0 auto; }
|
|
1139
|
+
.sprite-card { position: relative; }
|
|
1140
|
+
.wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
|
|
1141
|
+
transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
|
|
1142
|
+
@keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
|
|
1143
|
+
.sprite-node {
|
|
1144
|
+
margin-top: 1px; max-width: 100%; box-sizing: border-box;
|
|
1145
|
+
font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
|
|
1146
|
+
color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
|
|
1147
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1148
|
+
}
|
|
1149
|
+
.pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
|
|
1150
|
+
.mud-window.remote .pane-controls button { display: none; }
|
|
1151
|
+
.mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
|
|
1152
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1153
|
+
.wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
|
|
1154
|
+
}
|
|
888
1155
|
`;
|
|
889
1156
|
|
|
890
1157
|
/** The inlined page script, as a plain function body handed to
|
|
@@ -896,12 +1163,24 @@ function pageScript() {
|
|
|
896
1163
|
"use strict";
|
|
897
1164
|
const DATA = MUD_PAGE_DATA;
|
|
898
1165
|
const createTicker = ${createTicker.toString()};
|
|
1166
|
+
const createSerialQueue = ${createSerialQueue.toString()};
|
|
899
1167
|
const escapeHtml = ${escapeHtml.toString()};
|
|
900
1168
|
const esc = escapeHtml;
|
|
1169
|
+
const rowsForWorld = ${rowsForWorld.toString()};
|
|
1170
|
+
const appendLogLine = ${appendLogLine.toString()};
|
|
1171
|
+
const fnv1a32 = ${fnv1a32.toString()};
|
|
1172
|
+
const predatorSubjects = ${predatorSubjects.toString()};
|
|
901
1173
|
const paneMarkup = ${paneMarkup.toString()};
|
|
902
1174
|
const speciesOfCharacter = ${speciesOfCharacter.toString()};
|
|
903
1175
|
const mudRoomSceneObjects = ${mudRoomSceneObjects.toString()};
|
|
904
1176
|
const carriedItemsFor = ${carriedItemsFor.toString()};
|
|
1177
|
+
// EXIT_DELTA is data, not a function — directedGridLayout/roomGraphSvg both
|
|
1178
|
+
// close over it as a module-level const, which a \`.toString()\` splice never
|
|
1179
|
+
// carries (a spliced function's source text is its own body only), so it
|
|
1180
|
+
// travels here as JSON instead.
|
|
1181
|
+
const EXIT_DELTA = ${JSON.stringify(EXIT_DELTA)};
|
|
1182
|
+
const directedGridLayout = ${directedGridLayout.toString()};
|
|
1183
|
+
const roomGraphSvg = ${roomGraphSvg.toString()};
|
|
905
1184
|
const levelsOf = ${levelsOf.toString()};
|
|
906
1185
|
const charactersInRoom = ${charactersInRoom.toString()};
|
|
907
1186
|
const burrowGraph = ${burrowGraph.toString()};
|
|
@@ -918,9 +1197,37 @@ function pageScript() {
|
|
|
918
1197
|
const allRoomIds = ${allRoomIds.toString()};
|
|
919
1198
|
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
920
1199
|
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
1200
|
+
const provenanceStamps = ${provenanceStamps.toString()};
|
|
1201
|
+
const peerTerm = ${peerTerm.toString()};
|
|
1202
|
+
const claimantOf = ${claimantOf.toString()};
|
|
1203
|
+
const wavingCharacters = ${wavingCharacters.toString()};
|
|
1204
|
+
const nodeNameFor = ${nodeNameFor.toString()};
|
|
1205
|
+
const offerBlobIn = ${offerBlobIn.toString()};
|
|
1206
|
+
const wireStateLabel = ${wireStateLabel.toString()};
|
|
1207
|
+
const nodeRowsFor = ${nodeRowsFor.toString()};
|
|
1208
|
+
const nodeInitials = ${nodeInitials.toString()};
|
|
1209
|
+
const inviteLinkFor = ${inviteLinkFor.toString()};
|
|
1210
|
+
const inviteParamsFrom = ${inviteParamsFrom.toString()};
|
|
921
1211
|
|
|
922
1212
|
const el = (id) => document.getElementById(id);
|
|
923
|
-
|
|
1213
|
+
// Which of the shipped burrows is loaded. Every read of the world — the
|
|
1214
|
+
// session it is opened over, the roster it is cast from, the provenance
|
|
1215
|
+
// prefix edit mode filters on — goes through this one index, so switching
|
|
1216
|
+
// scenarios needs no second copy of any of them.
|
|
1217
|
+
let scenarioIndex = 0;
|
|
1218
|
+
const scenario = function () { return DATA.scenarios[scenarioIndex]; };
|
|
1219
|
+
const rosterOf = function (s) { return (s.characters || []).map(function (c) { return c.id; }); };
|
|
1220
|
+
// Where the survey starts laying rooms out: the burrow's own origin, the
|
|
1221
|
+
// same fact the dig mechanic measures its reach from. Falling back to the
|
|
1222
|
+
// shipped default only matters for a world that declares no origin at all,
|
|
1223
|
+
// and burrowGraph already copes by starting from whichever room sorts first.
|
|
1224
|
+
const rootRoomOf = function (s) {
|
|
1225
|
+
const facts = (s.worldPayload && s.worldPayload.facts) || [];
|
|
1226
|
+
const origin = facts.find(function (f) { return f.predicate === "mgx:is-origin" && f.object === "true"; });
|
|
1227
|
+
return origin ? origin.subject : DATA.rootRoom;
|
|
1228
|
+
};
|
|
1229
|
+
let roster = rosterOf(scenario());
|
|
1230
|
+
let rootRoom = rootRoomOf(scenario());
|
|
924
1231
|
let slots = [];
|
|
925
1232
|
const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
|
|
926
1233
|
const NPC_COLOR = "var(--chalk)";
|
|
@@ -933,12 +1240,8 @@ function pageScript() {
|
|
|
933
1240
|
// directly, so two characters' turns can never interleave their reads and
|
|
934
1241
|
// writes of the one shared memoryDir, and the global turn counter always
|
|
935
1242
|
// increments in real execution order.
|
|
936
|
-
let
|
|
937
|
-
function serializeTick(fn) {
|
|
938
|
-
const run = tickChain.then(fn, fn);
|
|
939
|
-
tickChain = run.catch(function () {});
|
|
940
|
-
return run;
|
|
941
|
-
}
|
|
1243
|
+
let tickQueue = createSerialQueue();
|
|
1244
|
+
function serializeTick(fn) { return tickQueue.run(fn); }
|
|
942
1245
|
|
|
943
1246
|
let session = null;
|
|
944
1247
|
let cast = [];
|
|
@@ -956,6 +1259,15 @@ function pageScript() {
|
|
|
956
1259
|
const liveWait = function () { return wait(delayMs); };
|
|
957
1260
|
|
|
958
1261
|
let freshlyDugRoom = null;
|
|
1262
|
+
// Which animals another node has claimed, character -> that node's name.
|
|
1263
|
+
// Refreshed from the store on every redraw, which is also every turn, so a
|
|
1264
|
+
// claim that arrives from a peer takes effect on the next tick rather than
|
|
1265
|
+
// being cached until something else happens to look.
|
|
1266
|
+
let claimedElsewhere = {};
|
|
1267
|
+
// Whoever is waving right now, and the timer that lets a wave stop being
|
|
1268
|
+
// current. A wave is never retracted — it just stops being recent.
|
|
1269
|
+
let wavingNow = [];
|
|
1270
|
+
let waveRedrawTimer = null;
|
|
959
1271
|
// The last omniscient read, kept so the pounce can draw the predator's own
|
|
960
1272
|
// den after the engine has already moved the character out of every room.
|
|
961
1273
|
let lastSnapshot = null;
|
|
@@ -991,10 +1303,15 @@ function pageScript() {
|
|
|
991
1303
|
const bootAt = bootSeq;
|
|
992
1304
|
return serializeTick(async function () {
|
|
993
1305
|
if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
|
|
1306
|
+
// An animal another node claimed is played there, not here. Its moves
|
|
1307
|
+
// still arrive as facts and it still draws, talks and can be waved at —
|
|
1308
|
+
// this page just never takes its turn for it.
|
|
1309
|
+
if (claimedElsewhere[character]) return { playedElsewhere: true };
|
|
994
1310
|
globalTurn += 1;
|
|
995
1311
|
const result = await session.windows[character].autoplayTick(globalTurn);
|
|
996
1312
|
if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
|
|
997
1313
|
afterEngineTurn(character, result);
|
|
1314
|
+
await broadcastLocalChange();
|
|
998
1315
|
return result;
|
|
999
1316
|
});
|
|
1000
1317
|
}
|
|
@@ -1027,12 +1344,10 @@ function pageScript() {
|
|
|
1027
1344
|
}
|
|
1028
1345
|
|
|
1029
1346
|
// Whichever individual the WORLD marks dangerous, never a species this page
|
|
1030
|
-
// hardcodes — the same
|
|
1347
|
+
// hardcodes — the same domain/mud-facts.mjs reader mud-turn.mjs's own
|
|
1348
|
+
// predator check reads server-side.
|
|
1031
1349
|
function predatorInRows(rows) {
|
|
1032
|
-
|
|
1033
|
-
if (row.predicate === "mgx:is-predator" && row.object === "true") return row.subject;
|
|
1034
|
-
}
|
|
1035
|
-
return null;
|
|
1350
|
+
return predatorSubjects(rows)[0] || null;
|
|
1036
1351
|
}
|
|
1037
1352
|
|
|
1038
1353
|
// The pane holds on the den for the length of one pounce before it grays out.
|
|
@@ -1089,7 +1404,7 @@ function pageScript() {
|
|
|
1089
1404
|
for (const action of result.actions || []) {
|
|
1090
1405
|
if (action.kind !== "ask" && action.kind !== "talk") continue;
|
|
1091
1406
|
const other = action.teller || action.target || action.object;
|
|
1092
|
-
noteSpeech(result.room, character, { text:
|
|
1407
|
+
if (action.text) noteSpeech(result.room, character, { text: action.text });
|
|
1093
1408
|
if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
|
|
1094
1409
|
else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
|
|
1095
1410
|
}
|
|
@@ -1115,23 +1430,7 @@ function pageScript() {
|
|
|
1115
1430
|
// thing stays readable through the popup, and stays in the DOM for anything
|
|
1116
1431
|
// reading the log.
|
|
1117
1432
|
function appendChat(character, cls, text) {
|
|
1118
|
-
|
|
1119
|
-
const d = document.createElement("div");
|
|
1120
|
-
d.className = cls;
|
|
1121
|
-
d.textContent = text;
|
|
1122
|
-
log.appendChild(d);
|
|
1123
|
-
// A line-clamped box reports scrollHeight EQUAL to its clamped height, so
|
|
1124
|
-
// the overflow has to be read against the same box with the clamp lifted.
|
|
1125
|
-
d.classList.add("unclamped");
|
|
1126
|
-
const wholeHeight = d.scrollHeight;
|
|
1127
|
-
d.classList.remove("unclamped");
|
|
1128
|
-
if (wholeHeight - d.clientHeight > 2) {
|
|
1129
|
-
d.classList.add("clipped");
|
|
1130
|
-
d.setAttribute("role", "button");
|
|
1131
|
-
d.setAttribute("tabindex", "0");
|
|
1132
|
-
d.setAttribute("title", "read the whole line");
|
|
1133
|
-
}
|
|
1134
|
-
log.scrollTop = log.scrollHeight;
|
|
1433
|
+
appendLogLine(el(paneIdFor(character) + "-chatlog"), cls, text, { clip: true });
|
|
1135
1434
|
}
|
|
1136
1435
|
|
|
1137
1436
|
function openLogPopup(slot, text) {
|
|
@@ -1146,13 +1445,42 @@ function pageScript() {
|
|
|
1146
1445
|
|
|
1147
1446
|
function sendCommand(character, line) {
|
|
1148
1447
|
appendChat(character, "u", line);
|
|
1149
|
-
|
|
1448
|
+
const owner = claimedElsewhere[character];
|
|
1449
|
+
if (owner) {
|
|
1450
|
+
const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
|
|
1451
|
+
appendChat(character, "a", refusal);
|
|
1452
|
+
return Promise.resolve({ answer: refusal, end: false });
|
|
1453
|
+
}
|
|
1454
|
+
// A wave is a gesture, not a world command — the engine has no verb for it
|
|
1455
|
+
// and would answer with an honest miss. Both ways in (this typed line and
|
|
1456
|
+
// the pane's own hand button) write the same fact.
|
|
1457
|
+
if (/^\\/?wave$/i.test(line.trim())) {
|
|
1458
|
+
return waveAs(character).then(function (here) {
|
|
1459
|
+
appendChat(character, "a", here
|
|
1460
|
+
? "you wave. Everyone in the " + here + " sees it."
|
|
1461
|
+
: character + " is standing nowhere, so there is nobody to wave at.");
|
|
1462
|
+
return { answer: "", end: false };
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
return serializeTick(function () { return session.windows[character].turn(line); }).then(async function (res) {
|
|
1150
1466
|
appendChat(character, "a", res.answer);
|
|
1467
|
+
await broadcastLocalChange();
|
|
1151
1468
|
renderSoon();
|
|
1152
1469
|
return res;
|
|
1153
1470
|
});
|
|
1154
1471
|
}
|
|
1155
1472
|
|
|
1473
|
+
// A wave is an ordinary add-only fact in the shared world, so it needs no
|
|
1474
|
+
// network to work: one browser renders it exactly the way four do, and the
|
|
1475
|
+
// room broadcast below is the only part sharing adds.
|
|
1476
|
+
async function waveAs(character) {
|
|
1477
|
+
if (!session || finished[character]) return null;
|
|
1478
|
+
const here = await serializeTick(function () { return session.wave(character); });
|
|
1479
|
+
if (here) await broadcastLocalChange();
|
|
1480
|
+
renderSoon();
|
|
1481
|
+
return here;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1156
1484
|
// Wired as the pane is built. The stage is rebuilt on every boot, so a
|
|
1157
1485
|
// pane's listeners live and die with it; each one still reads its slot's
|
|
1158
1486
|
// character at click time, because the cast is bound after the markup.
|
|
@@ -1177,6 +1505,10 @@ function pageScript() {
|
|
|
1177
1505
|
const character = characterInSlot(slot);
|
|
1178
1506
|
if (character) ensureTicker(character).stepOnce();
|
|
1179
1507
|
});
|
|
1508
|
+
el(w + "-wave").addEventListener("click", function () {
|
|
1509
|
+
const character = characterInSlot(slot);
|
|
1510
|
+
if (character) sendCommand(character, "wave");
|
|
1511
|
+
});
|
|
1180
1512
|
|
|
1181
1513
|
const log = el(w + "-chatlog");
|
|
1182
1514
|
log.addEventListener("click", function (e) {
|
|
@@ -1220,9 +1552,7 @@ function pageScript() {
|
|
|
1220
1552
|
|
|
1221
1553
|
// ---- room-view rendering -------------------------------------------------
|
|
1222
1554
|
function hashOf(text) {
|
|
1223
|
-
|
|
1224
|
-
for (let i = 0; i < String(text).length; i += 1) h = (h * 31 + String(text).charCodeAt(i)) % 100000;
|
|
1225
|
-
return h;
|
|
1555
|
+
return fnv1a32(String(text));
|
|
1226
1556
|
}
|
|
1227
1557
|
|
|
1228
1558
|
function spriteSvgFor(species, rows, instanceKey) {
|
|
@@ -1317,17 +1647,33 @@ function pageScript() {
|
|
|
1317
1647
|
|
|
1318
1648
|
// A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
|
|
1319
1649
|
// command this room currently grants on its subject (none, for a thing the
|
|
1320
|
-
// room offers nothing on) and the subject's own mass under the name.
|
|
1321
|
-
|
|
1650
|
+
// room offers nothing on) and the subject's own mass under the name. The
|
|
1651
|
+
// last argument holds the two things only a shared burrow has: a hand over a
|
|
1652
|
+
// character waving right now, and the node another peer plays it from — one
|
|
1653
|
+
// more line under the name, in the same font, never a second label system.
|
|
1654
|
+
function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
|
|
1655
|
+
const opts = extra || {};
|
|
1322
1656
|
const action = command
|
|
1323
1657
|
? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
|
|
1324
1658
|
: '" title="' + esc(label) + '"';
|
|
1325
|
-
return '<div class="sprite-card ' + (extraClass || "") +
|
|
1659
|
+
return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
|
|
1660
|
+
+ ' data-subject="' + esc(subject) + '">'
|
|
1661
|
+
+ (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
|
|
1326
1662
|
+ inner + '<div class="sprite-label">' + esc(label) + "</div>"
|
|
1663
|
+
+ (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
|
|
1327
1664
|
+ (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
|
|
1328
1665
|
+ "</div>";
|
|
1329
1666
|
}
|
|
1330
1667
|
|
|
1668
|
+
// A character another node plays carries that node's name; one this node
|
|
1669
|
+
// plays, or one nobody has claimed, carries nothing extra.
|
|
1670
|
+
function nodeLabelFor(character) {
|
|
1671
|
+
return claimedElsewhere[character] || null;
|
|
1672
|
+
}
|
|
1673
|
+
function isWavingNow(character) {
|
|
1674
|
+
return wavingNow.indexOf(character) !== -1;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1331
1677
|
function renderRoomView(character, rows, state, here, roomIds, roomMates) {
|
|
1332
1678
|
const w = paneIdFor(character);
|
|
1333
1679
|
drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
|
|
@@ -1337,12 +1683,14 @@ function pageScript() {
|
|
|
1337
1683
|
character, character,
|
|
1338
1684
|
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
|
|
1339
1685
|
null, "self", massOf(state, character),
|
|
1686
|
+
{ waving: isWavingNow(character), node: nodeLabelFor(character) },
|
|
1340
1687
|
);
|
|
1341
1688
|
|
|
1342
1689
|
const others = roomMates.map(function (mate) {
|
|
1343
1690
|
return spriteCardHtml(mate, mate,
|
|
1344
1691
|
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
|
|
1345
|
-
commands[mate], "", massOf(state, mate)
|
|
1692
|
+
commands[mate], "", massOf(state, mate),
|
|
1693
|
+
{ waving: isWavingNow(mate), node: nodeLabelFor(mate) });
|
|
1346
1694
|
});
|
|
1347
1695
|
const wall = [];
|
|
1348
1696
|
for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
|
|
@@ -1498,87 +1846,24 @@ function pageScript() {
|
|
|
1498
1846
|
// ---- the burrow survey ---------------------------------------------------
|
|
1499
1847
|
// One routine, drawn twice: the omniscient board in the top row (every room
|
|
1500
1848
|
// the world has) and each pane's own known ground (only what that character
|
|
1501
|
-
// has dug or walked, with a fading stub where it knows a way carries on).
|
|
1849
|
+
// has dug or walked, with a fading stub where it knows a way carries on). A
|
|
1850
|
+
// thin wrapper over the shared roomGraphSvg, fixed to the burrow's own
|
|
1851
|
+
// "turf" treatment (ground line, tunnels/shafts, per-room occupant dots)
|
|
1852
|
+
// and its own "burrow" wrapper class.
|
|
1502
1853
|
function burrowSvg(graph, options) {
|
|
1503
|
-
|
|
1504
|
-
const opts = options || {};
|
|
1505
|
-
const compact = !!opts.compact;
|
|
1506
|
-
const cellX = compact ? 44 : 76, cellY = compact ? 26 : 54;
|
|
1507
|
-
const roomW = compact ? 34 : 62, roomH = compact ? 14 : 26;
|
|
1508
|
-
const maxX = Math.max.apply(null, graph.nodes.map(function (n) { return n.x; }));
|
|
1509
|
-
const maxY = Math.max.apply(null, graph.nodes.map(function (n) { return n.y; }));
|
|
1510
|
-
const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
|
|
1511
|
-
const byRoom = {};
|
|
1512
|
-
for (const n of graph.nodes) byRoom[n.id] = n;
|
|
1513
|
-
const cx = function (n) { return (n.x + 0.5) * cellX; };
|
|
1514
|
-
const cy = function (n) { return (n.y + 0.5) * cellY; };
|
|
1515
|
-
|
|
1516
|
-
// The turf band is only drawn when the layout genuinely separates the
|
|
1517
|
-
// surface from the soil — a lateral dig can push a surface room onto a
|
|
1518
|
-
// lower row, and a ground line above it would say something false. The
|
|
1519
|
-
// room fills carry the same information either way, which is all the
|
|
1520
|
-
// thumbnail-sized copy has room for.
|
|
1521
|
-
const surfaceRows = graph.nodes.filter(function (n) { return n.level === 0; }).map(function (n) { return n.y; });
|
|
1522
|
-
const soilRows = graph.nodes.filter(function (n) { return n.level !== 0; }).map(function (n) { return n.y; });
|
|
1523
|
-
let turf = "";
|
|
1524
|
-
if (!compact && surfaceRows.length && soilRows.length && Math.max.apply(null, surfaceRows) < Math.min.apply(null, soilRows)) {
|
|
1525
|
-
const groundY = (Math.max.apply(null, surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
|
|
1526
|
-
turf = '<rect class="turf" x="0" y="0" width="' + w + '" height="' + groundY + '"></rect>'
|
|
1527
|
-
+ '<line class="ground-line" x1="0" y1="' + groundY + '" x2="' + w + '" y2="' + groundY + '"></line>';
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
const tunnels = graph.edges.map(function (e) {
|
|
1531
|
-
const a = byRoom[e.from], b = byRoom[e.to];
|
|
1532
|
-
if (!a || !b) return "";
|
|
1533
|
-
const shaft = e.direction === "up" || e.direction === "down" ? " shaft" : "";
|
|
1534
|
-
return '<line class="tunnel' + shaft + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
|
|
1535
|
-
}).join("");
|
|
1536
|
-
|
|
1537
|
-
const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
|
|
1538
|
-
const hints = graph.hints.map(function (hi) {
|
|
1539
|
-
const from = byRoom[hi.from];
|
|
1540
|
-
if (!from) return "";
|
|
1541
|
-
const d = HINT_DELTA[hi.direction] || [0, 0];
|
|
1542
|
-
return '<circle class="hint" cx="' + (cx(from) + d[0] * cellX * 0.36) + '" cy="' + (cy(from) + d[1] * cellY * 0.36) + '" r="' + (compact ? 2 : 3.2) + '"></circle>';
|
|
1543
|
-
}).join("");
|
|
1544
|
-
|
|
1545
|
-
const occupants = opts.occupants || {};
|
|
1546
|
-
const rooms = graph.nodes.map(function (n) {
|
|
1547
|
-
const here = opts.here === n.id ? " here" : "";
|
|
1548
|
-
const fresh = opts.fresh === n.id ? " freshly-dug" : "";
|
|
1549
|
-
const surface = n.level === 0 ? " surface" : "";
|
|
1550
|
-
const label = esc(n.id);
|
|
1551
|
-
const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
|
|
1552
|
-
? ' textLength="' + (roomW - 6) + '" lengthAdjust="spacingAndGlyphs"' : "";
|
|
1553
|
-
const cast = occupants[n.id] || [];
|
|
1554
|
-
// The room's own width is the budget: a dozen animals down one hole must
|
|
1555
|
-
// still read as a dozen dots inside that room rather than a line running
|
|
1556
|
-
// out of it, so the gap closes as the crowd grows.
|
|
1557
|
-
const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
|
|
1558
|
-
const dots = cast.map(function (c, i) {
|
|
1559
|
-
const spread = (i - (cast.length - 1) / 2) * step;
|
|
1560
|
-
return '<circle class="occupant" cx="' + (cx(n) + spread) + '" cy="' + (cy(n) + roomH / 2) + '" r="' + (compact ? 2.4 : 3.6)
|
|
1561
|
-
+ '" fill="' + c.color + '"><title>' + esc(c.character) + "</title></circle>";
|
|
1562
|
-
}).join("");
|
|
1563
|
-
return '<g class="room' + surface + here + fresh + '" data-room="' + label + '"><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2)
|
|
1564
|
-
+ '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect><text x="' + cx(n) + '" y="' + (cy(n) + (compact ? 2 : 2.5))
|
|
1565
|
-
+ '"' + fit + ">" + label + "</text>" + dots + "</g>";
|
|
1566
|
-
}).join("");
|
|
1567
|
-
|
|
1568
|
-
return '<div class="burrow"><svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="'
|
|
1569
|
-
+ esc(opts.label || "the burrow") + '">' + turf + tunnels + hints + rooms + "</svg></div>";
|
|
1854
|
+
return roomGraphSvg(graph, Object.assign({ turf: true, wrapClass: "burrow" }, options || {}));
|
|
1570
1855
|
}
|
|
1571
1856
|
|
|
1572
1857
|
function renderMinimap(character, rows, state, here) {
|
|
1573
1858
|
const visited = session.windows[character].visitedRoomIds();
|
|
1574
|
-
const graph = burrowGraph(state, visited,
|
|
1859
|
+
const graph = burrowGraph(state, visited, rootRoom);
|
|
1575
1860
|
el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
|
|
1576
1861
|
compact: true, here: here, label: "the ground " + character + " has covered",
|
|
1577
1862
|
});
|
|
1578
1863
|
}
|
|
1579
1864
|
|
|
1580
1865
|
function renderWorldMap(rows, state, roomIds) {
|
|
1581
|
-
const graph = burrowGraph(state, roomIds,
|
|
1866
|
+
const graph = burrowGraph(state, roomIds, rootRoom);
|
|
1582
1867
|
const occupants = {};
|
|
1583
1868
|
for (const room of roomIds) {
|
|
1584
1869
|
const here = charactersInRoom(state, room, everyone());
|
|
@@ -1636,13 +1921,35 @@ function pageScript() {
|
|
|
1636
1921
|
const snap = await session.snapshot();
|
|
1637
1922
|
lastSnapshot = snap;
|
|
1638
1923
|
const roomIds = allRoomIds(snap.rows);
|
|
1924
|
+
const nowMs = Date.now();
|
|
1925
|
+
wavingNow = wavingCharacters(snap.rows, nowMs);
|
|
1926
|
+
// Every claimed animal, not just the ones this page cast: a character
|
|
1927
|
+
// another node plays walks into this room as an ordinary room-mate, and
|
|
1928
|
+
// its label has to name where it is played from just the same.
|
|
1929
|
+
claimedElsewhere = {};
|
|
1930
|
+
if (myPeerId) {
|
|
1931
|
+
const claimed = {};
|
|
1932
|
+
for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
|
|
1933
|
+
for (const character of Object.keys(claimed)) {
|
|
1934
|
+
const owner = claimantOf(snap.rows, character);
|
|
1935
|
+
if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
|
|
1936
|
+
claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
|
|
1937
|
+
const ticker = tickers[character];
|
|
1938
|
+
if (ticker) ticker.pause();
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1639
1941
|
for (const character of everyone()) {
|
|
1640
1942
|
const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
|
|
1641
1943
|
if (reason) markOutOfPlay(character, reason, null);
|
|
1642
1944
|
}
|
|
1643
1945
|
renderWorldMap(snap.rows, snap.state, roomIds);
|
|
1644
1946
|
for (const character of cast) {
|
|
1645
|
-
|
|
1947
|
+
const pane = paneIdFor(character);
|
|
1948
|
+
el(pane + "-turn").textContent = "turn " + turnsFor(character);
|
|
1949
|
+
const owner = claimedElsewhere[character];
|
|
1950
|
+
el(pane).classList.toggle("remote", Boolean(owner));
|
|
1951
|
+
el(pane + "-node").hidden = !owner;
|
|
1952
|
+
el(pane + "-node").textContent = owner ? "played from " + owner : "";
|
|
1646
1953
|
if (finished[character]) continue;
|
|
1647
1954
|
const place = snap.state.placements.get(character);
|
|
1648
1955
|
const here = place ? place.object : null;
|
|
@@ -1655,6 +1962,12 @@ function pageScript() {
|
|
|
1655
1962
|
renderMinimap(character, snap.rows, snap.state, here);
|
|
1656
1963
|
}
|
|
1657
1964
|
freshlyDugRoom = null;
|
|
1965
|
+
// A wave carries no retraction, so nothing tells the page when one stops
|
|
1966
|
+
// being current — the recency window does, and only a redraw can notice it
|
|
1967
|
+
// has passed. One follow-up per second while any hand is up, and none at
|
|
1968
|
+
// all once they are all down.
|
|
1969
|
+
clearTimeout(waveRedrawTimer);
|
|
1970
|
+
if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
|
|
1658
1971
|
}
|
|
1659
1972
|
|
|
1660
1973
|
// ---- edit mode -----------------------------------------------------------
|
|
@@ -1675,14 +1988,11 @@ function pageScript() {
|
|
|
1675
1988
|
let editing = false;
|
|
1676
1989
|
|
|
1677
1990
|
function worldOnlyRows(rows) {
|
|
1678
|
-
|
|
1679
|
-
return (rows || []).filter(function (r) {
|
|
1680
|
-
return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
|
|
1681
|
-
});
|
|
1991
|
+
return rowsForWorld(rows, scenario().worldPayload.name);
|
|
1682
1992
|
}
|
|
1683
1993
|
|
|
1684
1994
|
function renderEditMap() {
|
|
1685
|
-
const graph = burrowGraph(editState, allRoomIds(editRows),
|
|
1995
|
+
const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
|
|
1686
1996
|
el("editMapBoard").innerHTML = graph.nodes.length
|
|
1687
1997
|
? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
|
|
1688
1998
|
: '<span class="edit-empty">no rooms written yet</span>';
|
|
@@ -1799,6 +2109,7 @@ function pageScript() {
|
|
|
1799
2109
|
status.className = "edit-status pending";
|
|
1800
2110
|
status.textContent = "reading the burrow\\u2026";
|
|
1801
2111
|
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
2112
|
+
await broadcastLocalChange();
|
|
1802
2113
|
const snap = await session.snapshot();
|
|
1803
2114
|
allStoreRows = snap.rows;
|
|
1804
2115
|
editRows = worldOnlyRows(snap.rows);
|
|
@@ -1883,6 +2194,433 @@ function pageScript() {
|
|
|
1883
2194
|
await renderAll();
|
|
1884
2195
|
}
|
|
1885
2196
|
|
|
2197
|
+
// ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
|
|
2198
|
+
// Every piece of networking arrives from ./vendor/p2p.js, the site's own
|
|
2199
|
+
// shared P2P asset, imported the first time somebody actually asks to share
|
|
2200
|
+
// rather than at boot. The room owns signaling, the mesh and the merge; this
|
|
2201
|
+
// block owns what a person sees and clicks, and nothing else.
|
|
2202
|
+
const P2P_ASSET = "./vendor/p2p.js";
|
|
2203
|
+
const NODE_NAME_KEY = "tmct.mud.nodeName";
|
|
2204
|
+
const invite = inviteParamsFrom(window.location.search);
|
|
2205
|
+
|
|
2206
|
+
let p2p = null;
|
|
2207
|
+
let p2pLoad = null;
|
|
2208
|
+
let room = null;
|
|
2209
|
+
let myPeerId = null;
|
|
2210
|
+
let myDisplayName = "";
|
|
2211
|
+
let worldId = "";
|
|
2212
|
+
let worldName = "";
|
|
2213
|
+
let nodeClockTimer = null;
|
|
2214
|
+
|
|
2215
|
+
function loadP2p() {
|
|
2216
|
+
if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
|
|
2217
|
+
return p2pLoad;
|
|
2218
|
+
}
|
|
2219
|
+
window.tmctP2pLoad = loadP2p;
|
|
2220
|
+
|
|
2221
|
+
function readStoredNodeName() {
|
|
2222
|
+
try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
|
|
2223
|
+
}
|
|
2224
|
+
function writeStoredNodeName(name) {
|
|
2225
|
+
try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
async function ensureIdentity() {
|
|
2229
|
+
const mod = await loadP2p();
|
|
2230
|
+
if (!myPeerId) myPeerId = mod.generatePeerId();
|
|
2231
|
+
if (!myDisplayName) {
|
|
2232
|
+
myDisplayName = readStoredNodeName() || mod.generateDisplayName();
|
|
2233
|
+
el("nodeNameInput").value = myDisplayName;
|
|
2234
|
+
}
|
|
2235
|
+
if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
|
|
2236
|
+
if (!worldName) {
|
|
2237
|
+
worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
|
|
2238
|
+
el("worldNameInput").value = worldName;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
async function ensureRoom() {
|
|
2243
|
+
if (room) return room;
|
|
2244
|
+
const mod = await loadP2p();
|
|
2245
|
+
await ensureIdentity();
|
|
2246
|
+
if (!session && bootRun) await bootRun;
|
|
2247
|
+
if (!session) throw new Error("the burrow hasn't finished opening");
|
|
2248
|
+
// The one place this page decides what is worth replicating: whatever the
|
|
2249
|
+
// engine calls live world state, plus the P2P layer's own four predicates.
|
|
2250
|
+
// The check is handed over rather than imported, so the sync filter never
|
|
2251
|
+
// learns the world engine's private predicate names.
|
|
2252
|
+
const extraPredicates = (window.tmctMud.P2P_PREDICATES || []).slice();
|
|
2253
|
+
const isState = window.tmctMud.isMudStatePredicate;
|
|
2254
|
+
room = mod.createP2pRoom({
|
|
2255
|
+
memoryDir: session.memoryDir,
|
|
2256
|
+
myPeerId: myPeerId,
|
|
2257
|
+
myDisplayName: myDisplayName,
|
|
2258
|
+
worldId: worldId,
|
|
2259
|
+
worldName: worldName,
|
|
2260
|
+
transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
|
|
2261
|
+
syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
|
|
2262
|
+
});
|
|
2263
|
+
room.onStateChanged(function (state) {
|
|
2264
|
+
// The join card exists to produce one reply. Once the channel is open it
|
|
2265
|
+
// has done its job, and a full-screen card over a live burrow is just
|
|
2266
|
+
// something in the way.
|
|
2267
|
+
if (state === "connected") el("joinCard").hidden = true;
|
|
2268
|
+
renderWire();
|
|
2269
|
+
});
|
|
2270
|
+
room.onPeersChanged(function () { renderNodes(); renderWire(); });
|
|
2271
|
+
room.onFactsChanged(function () { renderNodes(); renderSoon(); });
|
|
2272
|
+
await room.start();
|
|
2273
|
+
// The burrow's name is written into the graph the moment the room starts,
|
|
2274
|
+
// and this version retracts nothing — so the field stops taking edits.
|
|
2275
|
+
el("worldNameInput").readOnly = true;
|
|
2276
|
+
window.tmctP2pRoom = room;
|
|
2277
|
+
await claimMyAnimals();
|
|
2278
|
+
renderWire();
|
|
2279
|
+
renderNodes();
|
|
2280
|
+
if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
|
|
2281
|
+
return room;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Every animal this page drives is claimed the moment the burrow is shared,
|
|
2285
|
+
// panes and npcs alike: two peers each running scripted turns for one mole
|
|
2286
|
+
// would be two hands on one lemming. The claim is add-only and the oldest
|
|
2287
|
+
// wins, so a page that loses one simply stops driving that animal and
|
|
2288
|
+
// watches somebody else play it instead.
|
|
2289
|
+
async function claimMyAnimals() {
|
|
2290
|
+
if (!session || !room) return;
|
|
2291
|
+
await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
|
|
2292
|
+
await broadcastLocalChange();
|
|
2293
|
+
renderSoon();
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// The room diffs the store against what it last saw and broadcasts whatever
|
|
2297
|
+
// changed. The page calls it after every local turn, wave, claim and edit —
|
|
2298
|
+
// a broadcast that fails is a dead channel, reported by its own close
|
|
2299
|
+
// handler, and must never fail the turn that happened to carry it.
|
|
2300
|
+
function broadcastLocalChange() {
|
|
2301
|
+
if (!room) return Promise.resolve({ broadcast: 0 });
|
|
2302
|
+
return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
function dropRoom(note) {
|
|
2306
|
+
if (!room) return;
|
|
2307
|
+
room.close();
|
|
2308
|
+
room = null;
|
|
2309
|
+
window.tmctP2pRoom = null;
|
|
2310
|
+
clearInterval(nodeClockTimer);
|
|
2311
|
+
nodeClockTimer = null;
|
|
2312
|
+
el("shareLink").value = "";
|
|
2313
|
+
el("replyOut").value = "";
|
|
2314
|
+
el("worldNameInput").readOnly = false;
|
|
2315
|
+
renderWire();
|
|
2316
|
+
renderNodes();
|
|
2317
|
+
// Last, not first: renderWire restates the note for the state it has just
|
|
2318
|
+
// read, which for a room that is gone is the idle one ("this browser holds
|
|
2319
|
+
// the only copy"). WHY the room went is the thing worth saying, so it is
|
|
2320
|
+
// written over the top rather than under it.
|
|
2321
|
+
if (note) el("wireStateNote").textContent = note;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// The state machine is shared with chat.html and the words mostly are too.
|
|
2325
|
+
// Two of them are not: nothing is taught here and nothing is a node below,
|
|
2326
|
+
// so those two notes are said in this page's own terms rather than forking
|
|
2327
|
+
// the whole table for a burrow.
|
|
2328
|
+
const MUD_WIRE_NOTES = {
|
|
2329
|
+
idle: "this browser holds the only copy of this burrow.",
|
|
2330
|
+
connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
|
|
2331
|
+
};
|
|
2332
|
+
|
|
2333
|
+
function renderWire() {
|
|
2334
|
+
const state = room ? room.state : "idle";
|
|
2335
|
+
const label = wireStateLabel(state);
|
|
2336
|
+
const note = MUD_WIRE_NOTES[state] || label.note;
|
|
2337
|
+
const wire = el("wireState");
|
|
2338
|
+
wire.dataset.tone = label.tone;
|
|
2339
|
+
el("wireStateWord").textContent = label.word;
|
|
2340
|
+
el("wireStateNote").textContent = note;
|
|
2341
|
+
const pill = el("statePill");
|
|
2342
|
+
pill.dataset.tone = label.tone;
|
|
2343
|
+
el("statePillWord").textContent = label.pill;
|
|
2344
|
+
pill.title = note;
|
|
2345
|
+
el("sharePanel").hidden = !el("shareLink").value;
|
|
2346
|
+
// Once the channel is open the reply has been used; leaving "send this
|
|
2347
|
+
// back" on screen would be asking for something already done.
|
|
2348
|
+
el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function relativeWhen(at, nowMs) {
|
|
2352
|
+
if (at === null || at === undefined) return "\\u2014";
|
|
2353
|
+
const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
|
|
2354
|
+
if (seconds < 5) return "now";
|
|
2355
|
+
if (seconds < 60) return seconds + "s";
|
|
2356
|
+
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
2357
|
+
if (seconds < 86400) return Math.round(seconds / 3600) + "h";
|
|
2358
|
+
return Math.round(seconds / 86400) + "d";
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function renderNodes() {
|
|
2362
|
+
const listEl = el("nodeList");
|
|
2363
|
+
if (!room || !p2p) {
|
|
2364
|
+
el("nodeCount").textContent = "";
|
|
2365
|
+
listEl.textContent = "";
|
|
2366
|
+
el("nodeEmpty").hidden = false;
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
const rows = nodeRowsFor({
|
|
2370
|
+
peers: room.peers(),
|
|
2371
|
+
factRows: room.factRows(),
|
|
2372
|
+
myPeerId: myPeerId,
|
|
2373
|
+
myDisplayName: myDisplayName,
|
|
2374
|
+
nameFor: room.displayNameFor,
|
|
2375
|
+
latestTimestampOf: p2p.latestProvenanceTimestamp,
|
|
2376
|
+
});
|
|
2377
|
+
const nowMs = Date.now();
|
|
2378
|
+
const waving = wavingCharacters(room.factRows(), nowMs);
|
|
2379
|
+
el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
|
|
2380
|
+
listEl.textContent = "";
|
|
2381
|
+
for (const entry of rows) {
|
|
2382
|
+
const item = document.createElement("li");
|
|
2383
|
+
item.className = "node-row";
|
|
2384
|
+
item.dataset.self = String(entry.isSelf);
|
|
2385
|
+
item.dataset.away = String(!entry.connected);
|
|
2386
|
+
item.dataset.peer = entry.peerId;
|
|
2387
|
+
item.dataset.waving = String(waving.some(function (character) {
|
|
2388
|
+
const owner = claimantOf(room.factRows(), character);
|
|
2389
|
+
return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
|
|
2390
|
+
}));
|
|
2391
|
+
const avatar = document.createElement("span");
|
|
2392
|
+
avatar.className = "node-avatar";
|
|
2393
|
+
avatar.textContent = nodeInitials(entry.name);
|
|
2394
|
+
avatar.title = entry.connected
|
|
2395
|
+
? "connected"
|
|
2396
|
+
: "away \\u2014 closed the tab or dropped offline; everything it dug stays";
|
|
2397
|
+
const name = document.createElement("span");
|
|
2398
|
+
name.className = "node-name";
|
|
2399
|
+
name.textContent = entry.name;
|
|
2400
|
+
const hand = document.createElement("span");
|
|
2401
|
+
hand.className = "node-hand";
|
|
2402
|
+
hand.textContent = "\\u{1F44B}";
|
|
2403
|
+
const when = document.createElement("span");
|
|
2404
|
+
when.className = "node-when";
|
|
2405
|
+
when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
|
|
2406
|
+
item.appendChild(avatar);
|
|
2407
|
+
item.appendChild(name);
|
|
2408
|
+
if (entry.isSelf) {
|
|
2409
|
+
const self = document.createElement("span");
|
|
2410
|
+
self.className = "node-self";
|
|
2411
|
+
self.textContent = "you";
|
|
2412
|
+
item.appendChild(self);
|
|
2413
|
+
}
|
|
2414
|
+
item.appendChild(hand);
|
|
2415
|
+
item.appendChild(when);
|
|
2416
|
+
listEl.appendChild(item);
|
|
2417
|
+
}
|
|
2418
|
+
el("nodeEmpty").hidden = rows.length > 1;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
async function copyText(text) {
|
|
2422
|
+
try {
|
|
2423
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2424
|
+
await navigator.clipboard.writeText(text);
|
|
2425
|
+
return true;
|
|
2426
|
+
}
|
|
2427
|
+
} catch { /* fall through — the box on the page still holds the text */ }
|
|
2428
|
+
return false;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const openNetPanel = function () {
|
|
2432
|
+
el("netPanel").hidden = false;
|
|
2433
|
+
el("statePill").setAttribute("aria-expanded", "true");
|
|
2434
|
+
};
|
|
2435
|
+
const closeNetPanel = function () {
|
|
2436
|
+
el("netPanel").hidden = true;
|
|
2437
|
+
el("statePill").setAttribute("aria-expanded", "false");
|
|
2438
|
+
};
|
|
2439
|
+
|
|
2440
|
+
function wireNetPanel() {
|
|
2441
|
+
el("netPanelClose").addEventListener("click", closeNetPanel);
|
|
2442
|
+
el("statePill").addEventListener("click", function () {
|
|
2443
|
+
if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
el("shareBtn").addEventListener("click", async function () {
|
|
2447
|
+
const shareBtn = el("shareBtn");
|
|
2448
|
+
shareBtn.disabled = true;
|
|
2449
|
+
try {
|
|
2450
|
+
const active = await ensureRoom();
|
|
2451
|
+
const minted = await active.startSharing();
|
|
2452
|
+
el("shareLink").value = inviteLinkFor(window.location.href, {
|
|
2453
|
+
blob: minted.blob, world: worldId, worldName: worldName,
|
|
2454
|
+
});
|
|
2455
|
+
el("replyProblem").hidden = true;
|
|
2456
|
+
openNetPanel();
|
|
2457
|
+
renderWire();
|
|
2458
|
+
await copyText(el("shareLink").value);
|
|
2459
|
+
} catch (err) {
|
|
2460
|
+
el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
|
|
2461
|
+
el("replyProblem").hidden = false;
|
|
2462
|
+
openNetPanel();
|
|
2463
|
+
} finally {
|
|
2464
|
+
shareBtn.disabled = false;
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
// The inviter's page has exactly one paste target, so there is no wrong box
|
|
2469
|
+
// to choose. A rejected paste keeps its text so the copy can be fixed.
|
|
2470
|
+
el("replyBtn").addEventListener("click", async function () {
|
|
2471
|
+
let active = room;
|
|
2472
|
+
if (!active) {
|
|
2473
|
+
try { active = await ensureRoom(); } catch { return; }
|
|
2474
|
+
}
|
|
2475
|
+
const outcome = await active.completeInvite(el("replyBox").value);
|
|
2476
|
+
if (outcome && outcome.error) {
|
|
2477
|
+
el("replyProblem").textContent = outcome.message;
|
|
2478
|
+
el("replyProblem").hidden = false;
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
el("replyProblem").hidden = true;
|
|
2482
|
+
el("replyBox").value = "";
|
|
2483
|
+
renderWire();
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
|
|
2487
|
+
|
|
2488
|
+
// The link flow lands somebody on the join card before they have dug
|
|
2489
|
+
// anything. This is the other case: a burrow already open, an invite that
|
|
2490
|
+
// arrived in a message, and no reason to throw the burrow away to take it.
|
|
2491
|
+
el("joinOpenBtn").addEventListener("click", function () {
|
|
2492
|
+
el("invitePanel").hidden = false;
|
|
2493
|
+
openNetPanel();
|
|
2494
|
+
el("inviteBox").focus();
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
el("inviteBtn").addEventListener("click", async function () {
|
|
2498
|
+
const problem = el("inviteProblem");
|
|
2499
|
+
const pasted = el("inviteBox").value.trim();
|
|
2500
|
+
if (!pasted) {
|
|
2501
|
+
problem.textContent = "paste the invite you were sent, then try again";
|
|
2502
|
+
problem.hidden = false;
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
if (room) {
|
|
2506
|
+
problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
|
|
2507
|
+
problem.hidden = false;
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
// A whole link and a bare blob are both what people actually paste, so
|
|
2511
|
+
// both are read. The blob's own envelope decides either way.
|
|
2512
|
+
const blob = offerBlobIn(pasted);
|
|
2513
|
+
const mod = await loadP2p();
|
|
2514
|
+
const decoded = mod.decodeInviteBlob(blob);
|
|
2515
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2516
|
+
problem.textContent = decoded.error
|
|
2517
|
+
? "this invite looks cut short \\u2014 ask for it to be sent again"
|
|
2518
|
+
: "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
|
|
2519
|
+
problem.hidden = false;
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
// The world id has to be the invite's before the room is opened: a room
|
|
2523
|
+
// that minted its own would refuse the invite as belonging elsewhere.
|
|
2524
|
+
await ensureIdentity();
|
|
2525
|
+
worldId = decoded.value.world || worldId;
|
|
2526
|
+
if (decoded.value.worldName) {
|
|
2527
|
+
worldName = decoded.value.worldName;
|
|
2528
|
+
el("worldNameInput").value = worldName;
|
|
2529
|
+
}
|
|
2530
|
+
try {
|
|
2531
|
+
const active = await ensureRoom();
|
|
2532
|
+
const outcome = await active.acceptInvite(blob);
|
|
2533
|
+
if (outcome && outcome.error) {
|
|
2534
|
+
problem.textContent = outcome.message;
|
|
2535
|
+
problem.hidden = false;
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
problem.hidden = true;
|
|
2539
|
+
el("inviteBox").value = "";
|
|
2540
|
+
el("replyOut").value = outcome.blob;
|
|
2541
|
+
renderWire();
|
|
2542
|
+
await copyText(outcome.blob);
|
|
2543
|
+
} catch (err) {
|
|
2544
|
+
problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2545
|
+
problem.hidden = false;
|
|
2546
|
+
}
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
el("nodeNameInput").addEventListener("change", function () {
|
|
2550
|
+
const name = el("nodeNameInput").value.trim();
|
|
2551
|
+
if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
|
|
2552
|
+
myDisplayName = name;
|
|
2553
|
+
writeStoredNodeName(name);
|
|
2554
|
+
if (!room) { renderNodes(); return; }
|
|
2555
|
+
room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
|
|
2556
|
+
});
|
|
2557
|
+
el("worldNameInput").addEventListener("change", function () {
|
|
2558
|
+
const input = el("worldNameInput");
|
|
2559
|
+
if (input.readOnly) { input.value = worldName; return; }
|
|
2560
|
+
const name = input.value.trim();
|
|
2561
|
+
if (name) worldName = name;
|
|
2562
|
+
input.value = worldName;
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
el("joinBtn").addEventListener("click", async function () {
|
|
2566
|
+
const joinBtn = el("joinBtn");
|
|
2567
|
+
joinBtn.disabled = true;
|
|
2568
|
+
try {
|
|
2569
|
+
const active = await ensureRoom();
|
|
2570
|
+
const outcome = await active.acceptInvite(invite.offer);
|
|
2571
|
+
if (outcome && outcome.error) {
|
|
2572
|
+
el("joinProblem").textContent = outcome.message;
|
|
2573
|
+
el("joinProblem").hidden = false;
|
|
2574
|
+
joinBtn.disabled = false;
|
|
2575
|
+
joinBtn.textContent = "try again";
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
el("joinProblem").hidden = true;
|
|
2579
|
+
el("joinReply").value = outcome.blob;
|
|
2580
|
+
el("replyOut").value = outcome.blob;
|
|
2581
|
+
el("joinReplyWrap").hidden = false;
|
|
2582
|
+
joinBtn.textContent = "reply created";
|
|
2583
|
+
el("joinDismiss").textContent = "close this and start digging";
|
|
2584
|
+
renderWire();
|
|
2585
|
+
await copyText(outcome.blob);
|
|
2586
|
+
} catch (err) {
|
|
2587
|
+
el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2588
|
+
el("joinProblem").hidden = false;
|
|
2589
|
+
joinBtn.disabled = false;
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
|
|
2593
|
+
el("joinDismiss").addEventListener("click", function () {
|
|
2594
|
+
el("joinCard").hidden = true;
|
|
2595
|
+
if (el("replyOut").value) openNetPanel();
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// A link that lost part of itself on the way reads as a mangled link, never
|
|
2600
|
+
// as no invite at all.
|
|
2601
|
+
async function prepareJoinCard() {
|
|
2602
|
+
el("joinCard").hidden = false;
|
|
2603
|
+
el("joinWorld").textContent = invite.worldName || "a shared burrow";
|
|
2604
|
+
el("joinBtn").focus();
|
|
2605
|
+
const mod = await loadP2p();
|
|
2606
|
+
const decoded = mod.decodeInviteBlob(invite.offer);
|
|
2607
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2608
|
+
el("joinBtn").hidden = true;
|
|
2609
|
+
el("joinEyebrow").textContent = "this link didn't arrive in one piece";
|
|
2610
|
+
el("joinWorld").textContent = invite.worldName || "an invitation";
|
|
2611
|
+
el("joinBody").textContent = decoded.error
|
|
2612
|
+
? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
|
|
2613
|
+
: "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
|
|
2614
|
+
el("joinDismiss").textContent = "dig on your own instead";
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
if (decoded.value.world) invite.world = decoded.value.world;
|
|
2618
|
+
if (decoded.value.worldName) {
|
|
2619
|
+
invite.worldName = decoded.value.worldName;
|
|
2620
|
+
el("joinWorld").textContent = decoded.value.worldName;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
1886
2624
|
// ---- the control deck ----------------------------------------------------
|
|
1887
2625
|
// The players slider runs on detents (1, 2, 4), so its own value is the
|
|
1888
2626
|
// index of the count, never the count.
|
|
@@ -1942,6 +2680,22 @@ function pageScript() {
|
|
|
1942
2680
|
npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
|
|
1943
2681
|
npcCountSlider.addEventListener("change", function () { boot(); });
|
|
1944
2682
|
el("resetBtn").addEventListener("click", function () { boot(); });
|
|
2683
|
+
// Picking a burrow is a recast like any other, so it runs the same boot
|
|
2684
|
+
// the sliders and reset run. Edit mode follows the world rather than the
|
|
2685
|
+
// page: whatever is half-typed is flushed into the burrow it was typed
|
|
2686
|
+
// over, and the editor reopens on the one that just loaded.
|
|
2687
|
+
const scenarioSelect = el("scenarioSelect");
|
|
2688
|
+
if (scenarioSelect) {
|
|
2689
|
+
scenarioSelect.addEventListener("change", async function () {
|
|
2690
|
+
const picked = Number(scenarioSelect.value);
|
|
2691
|
+
if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
|
|
2692
|
+
const wasEditing = editing;
|
|
2693
|
+
if (wasEditing) await exitEditMode();
|
|
2694
|
+
scenarioIndex = picked;
|
|
2695
|
+
await boot("a different burrow opened \\u2014 still linked.");
|
|
2696
|
+
if (wasEditing) await enterEditMode();
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
1945
2699
|
el("editModeBtn").addEventListener("click", function () {
|
|
1946
2700
|
if (!session) return;
|
|
1947
2701
|
if (editing) exitEditMode(); else enterEditMode();
|
|
@@ -1996,9 +2750,31 @@ function pageScript() {
|
|
|
1996
2750
|
// session on the floor rather than binding it to the stage the newer boot
|
|
1997
2751
|
// has already drawn.
|
|
1998
2752
|
let bootSeq = 0;
|
|
1999
|
-
|
|
2753
|
+
let bootRun = null;
|
|
2754
|
+
function boot(note) {
|
|
2755
|
+
bootRun = openWorld(note);
|
|
2756
|
+
return bootRun;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
async function openWorld(note) {
|
|
2000
2760
|
const seq = bootSeq += 1;
|
|
2001
2761
|
autoOn = false;
|
|
2762
|
+
// A live link survives a recast: once the new burrow's store is open the
|
|
2763
|
+
// room re-binds to it (below), pushing the fresh world to every connected
|
|
2764
|
+
// node instead of quietly abandoning them. The recast moves the world one
|
|
2765
|
+
// epoch forward, read from the store the peers have all converged on, so
|
|
2766
|
+
// nobody's leftover snapshots from the old run can outrank the new one.
|
|
2767
|
+
const liveRoom = room;
|
|
2768
|
+
let nextEpoch = 0;
|
|
2769
|
+
if (liveRoom && session) {
|
|
2770
|
+
try {
|
|
2771
|
+
nextEpoch = (await session.snapshot()).state.epoch + 1;
|
|
2772
|
+
} catch (err) {
|
|
2773
|
+
nextEpoch = 1;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
claimedElsewhere = {};
|
|
2777
|
+
wavingNow = [];
|
|
2002
2778
|
const playBtn = el("autoToggle");
|
|
2003
2779
|
playBtn.setAttribute("aria-pressed", "false");
|
|
2004
2780
|
playBtn.textContent = "\\u25B6 play";
|
|
@@ -2008,7 +2784,7 @@ function pageScript() {
|
|
|
2008
2784
|
freshlyDugRoom = null;
|
|
2009
2785
|
lastSnapshot = null;
|
|
2010
2786
|
speechBubbles.clear();
|
|
2011
|
-
|
|
2787
|
+
tickQueue = createSerialQueue();
|
|
2012
2788
|
// Rebuilt rather than pruned: a character that had a pane last boot can be
|
|
2013
2789
|
// an npc in this one, and a stale seat would send its ending to a pane now
|
|
2014
2790
|
// showing somebody else.
|
|
@@ -2023,6 +2799,10 @@ function pageScript() {
|
|
|
2023
2799
|
// animal it has and no empty pane. The npcs draw from what the panes left,
|
|
2024
2800
|
// out of a roster grown to hold everyone, so no animal is ever both.
|
|
2025
2801
|
const npcCount = chosenNpcCount();
|
|
2802
|
+
// Read fresh, never cached at load: each burrow authors its own animals,
|
|
2803
|
+
// so the roster belongs to the scenario rather than to the page.
|
|
2804
|
+
roster = rosterOf(scenario());
|
|
2805
|
+
rootRoom = rootRoomOf(scenario());
|
|
2026
2806
|
cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
|
|
2027
2807
|
const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
|
|
2028
2808
|
.filter(function (id) { return cast.indexOf(id) === -1; });
|
|
@@ -2031,9 +2811,23 @@ function pageScript() {
|
|
|
2031
2811
|
showPlayerCount(cast.length);
|
|
2032
2812
|
showNpcCount(npcs.length);
|
|
2033
2813
|
renderStage();
|
|
2034
|
-
const opened = await window.tmctMud.createMudSession(
|
|
2814
|
+
const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone(), epoch: nextEpoch });
|
|
2035
2815
|
if (seq !== bootSeq) return;
|
|
2036
2816
|
session = opened;
|
|
2817
|
+
if (liveRoom) {
|
|
2818
|
+
try {
|
|
2819
|
+
await liveRoom.rebind({ memoryDir: opened.memoryDir, worldName: worldName, myDisplayName: myDisplayName });
|
|
2820
|
+
renderWire();
|
|
2821
|
+
renderNodes();
|
|
2822
|
+
// After renderWire, the same way dropRoom writes its own reason: the
|
|
2823
|
+
// render restates the state's stock note, and WHY this recast kept
|
|
2824
|
+
// the link is the thing worth saying over it.
|
|
2825
|
+
el("wireStateNote").textContent = note || "the burrow recast \\u2014 still linked.";
|
|
2826
|
+
await claimMyAnimals();
|
|
2827
|
+
} catch (err) {
|
|
2828
|
+
dropRoom("the link didn't survive the recast \\u2014 share again to link up.");
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2037
2831
|
for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
|
|
2038
2832
|
bindPanes();
|
|
2039
2833
|
for (const character of cast) {
|
|
@@ -2050,6 +2844,20 @@ function pageScript() {
|
|
|
2050
2844
|
}
|
|
2051
2845
|
|
|
2052
2846
|
wireDeck();
|
|
2847
|
+
wireNetPanel();
|
|
2848
|
+
renderWire();
|
|
2849
|
+
renderNodes();
|
|
2053
2850
|
boot();
|
|
2851
|
+
// Off the boot path on purpose: this fetches the shared P2P asset so the
|
|
2852
|
+
// panel can show a real node name and burrow name before anyone clicks
|
|
2853
|
+
// anything. A failure here costs sharing, never the digging.
|
|
2854
|
+
ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
|
|
2855
|
+
if (invite) {
|
|
2856
|
+
prepareJoinCard().catch(function (err) {
|
|
2857
|
+
el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
|
|
2858
|
+
el("joinProblem").hidden = false;
|
|
2859
|
+
el("joinBtn").hidden = true;
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2054
2862
|
})();`;
|
|
2055
2863
|
}
|