@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.0.1
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 +176 -8
- 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 +34 -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/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/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-viz.mjs +130 -47
- package/src/services/adventure.mjs +440 -367
- package/src/services/chat-page-viz.mjs +37 -9
- package/src/services/chat.mjs +99 -18
- package/src/services/code-explorer-viz.mjs +52 -14
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +54 -26
- package/src/services/ledger-viz.mjs +105 -56
- package/src/services/memory-panel-viz.mjs +24 -0
- package/src/services/mud-viz.mjs +940 -21
- package/src/services/plan-viz.mjs +119 -61
- package/src/services/research-viz.mjs +151 -57
- package/src/services/spider-fly-turn.mjs +1 -1
- package/src/services/spider-fly-viz.mjs +67 -43
- package/src/services/sprite-catalog-viz.mjs +175 -51
- package/src/services/viz-theme.mjs +15 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
- package/src/surfaces/web/mud-browser-entry.mjs +44 -2
package/src/services/mud-viz.mjs
CHANGED
|
@@ -73,13 +73,16 @@
|
|
|
73
73
|
// queue is also what makes the deck's GLOBAL turn counter well-defined: it
|
|
74
74
|
// increments in the exact order turns actually executed, never a race
|
|
75
75
|
// between panes.
|
|
76
|
-
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
|
|
76
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel } from "./viz-theme.mjs";
|
|
77
77
|
import { createTicker } from "./viz-ticker.mjs";
|
|
78
78
|
import {
|
|
79
79
|
roomSceneObjects, scenePlacement, spriteClassForObject, spriteAncestryRows, factsForSubject,
|
|
80
80
|
visibleRoomOf, roomKindForRoom, allRoomIds,
|
|
81
81
|
} from "./adventure-viz.mjs";
|
|
82
82
|
import { renderMudEditorText, wordBeforeCursor } from "./mud-editor.mjs";
|
|
83
|
+
import {
|
|
84
|
+
wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
|
|
85
|
+
} from "./chat-page-viz.mjs";
|
|
83
86
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
84
87
|
|
|
85
88
|
const DEFAULT_TITLE = "tmct — the mud";
|
|
@@ -110,6 +113,7 @@ const DEFAULT_MAX_TURNS = 400;
|
|
|
110
113
|
const MUD_NOTE_LINES = [
|
|
111
114
|
"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
115
|
`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.`,
|
|
116
|
+
"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
117
|
];
|
|
114
118
|
|
|
115
119
|
/** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
|
|
@@ -269,6 +273,106 @@ export function isCreature(state, subject) {
|
|
|
269
273
|
return state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
270
274
|
}
|
|
271
275
|
|
|
276
|
+
// ---- the shared burrow: reading the P2P layer's own facts back ---------------
|
|
277
|
+
// All four readers below work on plain fact rows and nothing else, so the page
|
|
278
|
+
// draws a claim, a wave and a node name identically whether the world is shared
|
|
279
|
+
// with three machines or with none. Pure and `.toString()`-splice safe.
|
|
280
|
+
|
|
281
|
+
/** Every assertion time a provenance string carries, in milliseconds. A tag is
|
|
282
|
+
* `<kind>:<id>@<ISO>`, and a fact asserted more than once holds several joined
|
|
283
|
+
* by " | " — a peer's relabelled copy of a tag keeps the time the fact was
|
|
284
|
+
* actually asserted, so reading them all back is what lets a claim be settled
|
|
285
|
+
* by its oldest and a wave by its newest. Pure, self-contained. */
|
|
286
|
+
export function provenanceStamps(provenance) {
|
|
287
|
+
const stamps = [];
|
|
288
|
+
const text = String(provenance || "");
|
|
289
|
+
const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
|
|
290
|
+
let found = pattern.exec(text);
|
|
291
|
+
while (found !== null) {
|
|
292
|
+
const at = Date.parse(found[1]);
|
|
293
|
+
if (!Number.isNaN(at)) stamps.push(at);
|
|
294
|
+
found = pattern.exec(text);
|
|
295
|
+
}
|
|
296
|
+
return stamps;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A peer id as the fact store actually holds it. `appendFacts` normalizes a
|
|
300
|
+
* term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
|
|
301
|
+
* claim is written with reads back as the bare id — a comparison against a
|
|
302
|
+
* raw peer id has to normalize the same way or it never matches. Pure,
|
|
303
|
+
* self-contained. */
|
|
304
|
+
export function peerTerm(peerId) {
|
|
305
|
+
return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Which peer plays `character`, or null when nobody has claimed it. Claims are
|
|
309
|
+
* add-only and never retracted, so two peers claiming the same animal both
|
|
310
|
+
* write and every page settles it the same way: the OLDEST claim wins, with
|
|
311
|
+
* the lower peer term breaking a dead heat so two machines reading the same
|
|
312
|
+
* rows can never disagree. Pure, self-contained (provenanceStamps is spliced
|
|
313
|
+
* alongside it). */
|
|
314
|
+
export function claimantOf(rows, character) {
|
|
315
|
+
let owner = null;
|
|
316
|
+
let ownerAt = Infinity;
|
|
317
|
+
for (const row of rows || []) {
|
|
318
|
+
if (row.subject !== character || row.predicate !== "mgx:playedBy") continue;
|
|
319
|
+
let at = Infinity;
|
|
320
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp < at) at = stamp;
|
|
321
|
+
if (at < ownerAt || (at === ownerAt && owner !== null && String(row.object) < owner)) {
|
|
322
|
+
ownerAt = at;
|
|
323
|
+
owner = String(row.object);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return owner;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Which characters are waving right now — a read-time recency question over
|
|
330
|
+
* the newest tag on each `mgx:waved` fact, never stored state and never a
|
|
331
|
+
* retraction. A wave older than the window simply stops being read as one.
|
|
332
|
+
* Pure, self-contained. */
|
|
333
|
+
export function wavingCharacters(rows, nowMs, windowMs = 8000) {
|
|
334
|
+
const waving = [];
|
|
335
|
+
for (const row of rows || []) {
|
|
336
|
+
if (row.predicate !== "mgx:waved") continue;
|
|
337
|
+
let newest = null;
|
|
338
|
+
for (const stamp of provenanceStamps(row.provenance)) if (newest === null || stamp > newest) newest = stamp;
|
|
339
|
+
if (newest === null) continue;
|
|
340
|
+
const age = nowMs - newest;
|
|
341
|
+
if (age < 0 || age > windowMs) continue;
|
|
342
|
+
if (waving.indexOf(row.subject) === -1) waving.push(row.subject);
|
|
343
|
+
}
|
|
344
|
+
return waving;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The offer blob inside whatever somebody actually pasted: the `offer`
|
|
348
|
+
* parameter of a whole invite link, or the bare blob when that is what
|
|
349
|
+
* arrived. Both are what people really paste, and telling them apart by
|
|
350
|
+
* looking is cheaper than asking them to. Pure, self-contained. */
|
|
351
|
+
export function offerBlobIn(pasted) {
|
|
352
|
+
const text = String(pasted || "").trim();
|
|
353
|
+
const marker = text.indexOf("offer=");
|
|
354
|
+
if (marker === -1) return text;
|
|
355
|
+
const rest = text.slice(marker + "offer=".length);
|
|
356
|
+
const end = rest.search(/[&#\s]/);
|
|
357
|
+
return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
|
|
361
|
+
* falling back to a shortened peer id. A label never waits for a name to
|
|
362
|
+
* arrive. Pure, self-contained. */
|
|
363
|
+
export function nodeNameFor(rows, peerId) {
|
|
364
|
+
const want = peerTerm(peerId);
|
|
365
|
+
let name = null;
|
|
366
|
+
let nameAt = -Infinity;
|
|
367
|
+
for (const row of rows || []) {
|
|
368
|
+
if (row.predicate !== "mgx:nodeName" || peerTerm(row.subject) !== want) continue;
|
|
369
|
+
let at = -Infinity;
|
|
370
|
+
for (const stamp of provenanceStamps(row.provenance)) if (stamp > at) at = stamp;
|
|
371
|
+
if (at >= nameAt) { nameAt = at; name = String(row.object); }
|
|
372
|
+
}
|
|
373
|
+
return name || want.slice(0, 8);
|
|
374
|
+
}
|
|
375
|
+
|
|
272
376
|
/** The self-contained mud.html page. Pure — identical output for identical
|
|
273
377
|
* input; every other piece of state (the live world, chat, ticks) is
|
|
274
378
|
* computed in the browser once the sibling bundle loads.
|
|
@@ -280,7 +384,12 @@ export function isCreature(state, subject) {
|
|
|
280
384
|
* the roster cannot fill draws as many panes as it has animals.
|
|
281
385
|
* `worldPayload` is `{ name, facts, rules, opening }`, read once at build
|
|
282
386
|
* time through the real worlds-pack provider (see this module's own
|
|
283
|
-
* header). `
|
|
387
|
+
* header). `scenarios` is the list of burrows the deck's dropdown offers,
|
|
388
|
+
* `[{ label, worldPayload, characters }]` — each one a whole world with its
|
|
389
|
+
* own roster, so picking one recasts the page over that world's animals
|
|
390
|
+
* rather than reskinning this one. `worldPayload`/`characters` name the
|
|
391
|
+
* scenario the page opens with; a page given no `scenarios` is the
|
|
392
|
+
* one-burrow case and ships no dropdown at all. `spriteTemplates` is the
|
|
284
393
|
* large-tier sprite set (data/sprites-large/*.toml) so every species
|
|
285
394
|
* resolves its own art instead of falling back to the flat animal icon.
|
|
286
395
|
* `mudConfig` defaults to game-config.mjs's own DEFAULT_GAME_CONFIG.mud —
|
|
@@ -293,13 +402,21 @@ export function renderMudHtml({
|
|
|
293
402
|
title = DEFAULT_TITLE,
|
|
294
403
|
worldPayload,
|
|
295
404
|
characters = [],
|
|
405
|
+
scenarios = [],
|
|
296
406
|
spriteTemplates = [],
|
|
297
407
|
mudConfig = DEFAULT_GAME_CONFIG.mud,
|
|
298
408
|
engineBundleJs = "",
|
|
299
409
|
} = {}) {
|
|
300
|
-
const
|
|
410
|
+
const scenarioList = scenarios.length
|
|
411
|
+
? scenarios
|
|
412
|
+
: [{ label: scenarioLabel(worldPayload?.name), worldPayload, characters }];
|
|
413
|
+
const opening = scenarioList[0];
|
|
414
|
+
const openingCount = Math.max(
|
|
415
|
+
1,
|
|
416
|
+
Math.min(DEFAULT_PLAYER_COUNT, (opening.characters || []).length || DEFAULT_PLAYER_COUNT),
|
|
417
|
+
);
|
|
301
418
|
const pageData = embedJson({
|
|
302
|
-
|
|
419
|
+
scenarios: scenarioList, spriteTemplates, mudConfig,
|
|
303
420
|
paneSlots: PANE_SLOTS,
|
|
304
421
|
playerCounts: PLAYER_COUNTS,
|
|
305
422
|
defaultPlayerCount: DEFAULT_PLAYER_COUNT,
|
|
@@ -333,7 +450,14 @@ ${MUD_STYLE}
|
|
|
333
450
|
<div class="deck-controls">
|
|
334
451
|
<button type="button" class="deck-play" id="autoToggle" aria-pressed="false">▶ play</button>
|
|
335
452
|
<button type="button" id="resetBtn">reset</button>
|
|
453
|
+
${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-select" aria-label="which burrow to play">
|
|
454
|
+
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
455
|
+
</select>` : ""}
|
|
336
456
|
<button type="button" id="editModeBtn" aria-pressed="false">edit</button>
|
|
457
|
+
<button type="button" id="shareBtn">share</button>
|
|
458
|
+
<button type="button" id="joinOpenBtn">join</button>
|
|
459
|
+
<button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
|
|
460
|
+
title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
|
|
337
461
|
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
338
462
|
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
339
463
|
</div>
|
|
@@ -365,8 +489,7 @@ ${MUD_STYLE}
|
|
|
365
489
|
</label>
|
|
366
490
|
</div>
|
|
367
491
|
<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>
|
|
492
|
+
${MUD_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
|
|
370
493
|
<button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">×</button>
|
|
371
494
|
</div>
|
|
372
495
|
</section>
|
|
@@ -379,6 +502,61 @@ ${MUD_STYLE}
|
|
|
379
502
|
<div class="world-map-key" id="worldMapKey"></div>
|
|
380
503
|
</section>
|
|
381
504
|
</div>
|
|
505
|
+
<section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
|
|
506
|
+
<div class="net-head">
|
|
507
|
+
<span class="net-title">the shared burrow</span>
|
|
508
|
+
<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>
|
|
509
|
+
<button type="button" class="net-close" id="netPanelClose" aria-label="close">×</button>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="net-fields">
|
|
512
|
+
<label class="net-field">this node
|
|
513
|
+
<input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
|
|
514
|
+
</label>
|
|
515
|
+
<label class="net-field">this burrow
|
|
516
|
+
<input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
|
|
517
|
+
</label>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="net-block" id="sharePanel" hidden>
|
|
520
|
+
<p class="net-hint">Each link invites one person. Share again for the next.</p>
|
|
521
|
+
<textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
|
|
522
|
+
</div>
|
|
523
|
+
<div class="net-block" id="invitePanel" hidden>
|
|
524
|
+
<label class="net-label" for="inviteBox">paste the invite you were sent</label>
|
|
525
|
+
<textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
|
|
526
|
+
<button type="button" id="inviteBtn">make my reply</button>
|
|
527
|
+
<p class="net-problem" id="inviteProblem" role="alert" hidden></p>
|
|
528
|
+
</div>
|
|
529
|
+
<div class="net-block" id="replyPanel">
|
|
530
|
+
<label class="net-label" for="replyBox">paste their reply here</label>
|
|
531
|
+
<textarea id="replyBox" rows="2" spellcheck="false"></textarea>
|
|
532
|
+
<button type="button" id="replyBtn">connect</button>
|
|
533
|
+
<p class="net-problem" id="replyProblem" role="alert" hidden></p>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="net-block" id="answerPanel" hidden>
|
|
536
|
+
<label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
|
|
537
|
+
<textarea id="replyOut" readonly rows="2"></textarea>
|
|
538
|
+
<button type="button" id="copyReplyBtn">copy the reply</button>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="net-block">
|
|
541
|
+
<div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
|
|
542
|
+
<ul class="node-list" id="nodeList"></ul>
|
|
543
|
+
<p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
|
|
544
|
+
</div>
|
|
545
|
+
</section>
|
|
546
|
+
<div class="join-card" id="joinCard" hidden>
|
|
547
|
+
<div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
|
|
548
|
+
<p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
|
|
549
|
+
<h2 class="join-world" id="joinWorld">a shared burrow</h2>
|
|
550
|
+
<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>
|
|
551
|
+
<button type="button" id="joinBtn">create my reply</button>
|
|
552
|
+
<p class="net-problem" id="joinProblem" role="alert" hidden></p>
|
|
553
|
+
<div class="join-reply" id="joinReplyWrap" hidden>
|
|
554
|
+
<textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
|
|
555
|
+
<button type="button" id="joinCopyBtn">copy the reply</button>
|
|
556
|
+
</div>
|
|
557
|
+
<button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
382
560
|
<div class="mud-stage" id="mudStage" data-panes="${openingCount}">
|
|
383
561
|
${paneHtml}
|
|
384
562
|
</div>
|
|
@@ -430,6 +608,7 @@ export function paneMarkup(slot) {
|
|
|
430
608
|
return ` <section class="mud-window pane-${escapeHtml(slot)}" id="${w}" data-slot="${escapeHtml(slot)}" data-character="">
|
|
431
609
|
<div class="pane-head">
|
|
432
610
|
<h2 id="${w}-name">waiting</h2>
|
|
611
|
+
<span class="pane-node" id="${w}-node" hidden></span>
|
|
433
612
|
<span class="mono pane-turn" id="${w}-turn">turn 0</span>
|
|
434
613
|
</div>
|
|
435
614
|
<div class="room-stage">
|
|
@@ -473,7 +652,11 @@ export function paneMarkup(slot) {
|
|
|
473
652
|
</div>
|
|
474
653
|
</div>
|
|
475
654
|
<div class="chat-console">
|
|
476
|
-
<div class="
|
|
655
|
+
<div class="pill-strip">
|
|
656
|
+
<button type="button" class="pill wave-btn" id="${w}-wave" title="wave, so everyone in this room sees it"
|
|
657
|
+
aria-label="wave">👋 wave</button>
|
|
658
|
+
<div class="chatpills" id="${w}-chatpills" role="group" aria-label="quick commands"></div>
|
|
659
|
+
</div>
|
|
477
660
|
<form class="chatask" id="${w}-chatform">
|
|
478
661
|
<span class="prompt mono">tmct></span>
|
|
479
662
|
<input id="${w}-chatq" type="text" placeholder="look" aria-label="type a command" disabled>
|
|
@@ -530,6 +713,12 @@ const MUD_STYLE = `
|
|
|
530
713
|
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px; background: rgba(255,255,255,.5);
|
|
531
714
|
}
|
|
532
715
|
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) { border-color: var(--burrow-glow); }
|
|
716
|
+
.deck-select {
|
|
717
|
+
font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
|
|
718
|
+
padding: .32rem .7rem; border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
719
|
+
background: rgba(255,255,255,.5); color: var(--mud-ink);
|
|
720
|
+
}
|
|
721
|
+
.deck-select:hover { border-color: var(--burrow-glow); }
|
|
533
722
|
.deck-play { background: var(--mud-ink) !important; color: var(--parchment); border-color: var(--mud-ink) !important; padding: .38rem 1.1rem !important; }
|
|
534
723
|
.deck-play[aria-pressed="true"] { background: var(--burrow-glow) !important; border-color: var(--burrow-glow) !important; color: var(--mud-ink); }
|
|
535
724
|
.deck-turns { margin-left: auto; font-size: .74rem; color: var(--soil-mid); }
|
|
@@ -885,6 +1074,138 @@ const MUD_STYLE = `
|
|
|
885
1074
|
.edit-stage { grid-template-columns: 1fr; }
|
|
886
1075
|
#editorText { min-height: 18rem; }
|
|
887
1076
|
}
|
|
1077
|
+
|
|
1078
|
+
/* ---- the control panel, in a Lemmings register, on the burrow's own palette ----
|
|
1079
|
+
The 1991 game put everything a player touches in one chunky beveled strip
|
|
1080
|
+
under the level. This deck keeps that shape — one bordered block, a play
|
|
1081
|
+
button that reads as the "go" key, a lit corner readout — but in the same
|
|
1082
|
+
soil/parchment/burrow-glow palette every other panel on the page already
|
|
1083
|
+
uses, so it reads as this world's own control panel rather than a
|
|
1084
|
+
different game's console dropped on top of it. Everything below overrides
|
|
1085
|
+
the parchment deck above it deliberately, so the panel is one block a
|
|
1086
|
+
reader can find rather than a dozen edits. */
|
|
1087
|
+
:root {
|
|
1088
|
+
--lem-face: var(--chalk); --lem-face-hi: #FBF7EF; --lem-face-lo: var(--soil-light);
|
|
1089
|
+
--lem-go: var(--mud-ink); --lem-go-hi: var(--burrow-glow);
|
|
1090
|
+
--lem-alert: #A5432B;
|
|
1091
|
+
--lem-readout: var(--burrow-glow); --lem-readout-bg: var(--soil-deep);
|
|
1092
|
+
--lem-chalk: var(--soil-mid);
|
|
1093
|
+
}
|
|
1094
|
+
.deck-slider, .deck h3 { color: var(--lem-chalk); }
|
|
1095
|
+
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
|
|
1096
|
+
.deck button, .pane-controls button, .net-panel button, .join-inner button {
|
|
1097
|
+
background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
1098
|
+
padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
|
|
1099
|
+
}
|
|
1100
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
|
|
1101
|
+
.net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
|
|
1102
|
+
background: var(--lem-face-hi); border-color: var(--burrow-glow);
|
|
1103
|
+
}
|
|
1104
|
+
.deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
|
|
1105
|
+
.net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
|
|
1106
|
+
background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
|
|
1107
|
+
}
|
|
1108
|
+
.deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
|
|
1109
|
+
.deck-play[aria-pressed="true"] { background: var(--lem-alert) !important; border-color: var(--lem-alert) !important; color: var(--parchment) !important; }
|
|
1110
|
+
/* The corner readout: a lit panel display, in the burrow's own amber rather than DOS-console green. */
|
|
1111
|
+
.deck-turns { background: var(--lem-readout-bg); color: var(--lem-readout);
|
|
1112
|
+
border: 1px solid var(--soil-mid); border-radius: 2px; padding: .1rem .5rem; }
|
|
1113
|
+
.deck-info-btn { background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); }
|
|
1114
|
+
.deck-info-btn:hover, .deck-info-btn[aria-expanded="true"] { background: var(--lem-face-hi); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1115
|
+
#editModeBtn[aria-pressed="true"] { background: var(--burrow-glow); border-color: var(--burrow-glow); color: var(--mud-ink); }
|
|
1116
|
+
|
|
1117
|
+
/* ---- whether the burrow is shared, said in one word on the panel ---- */
|
|
1118
|
+
.state-pill { display: inline-flex; align-items: center; gap: .34rem;
|
|
1119
|
+
font-family: ${MONO_STACK}; font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1120
|
+
.state-dot { width: .46rem; height: .46rem; border-radius: 50%; background: var(--lem-face-lo); flex: 0 0 auto; }
|
|
1121
|
+
.state-pill[data-tone="waiting"] .state-dot { background: #E0912A; }
|
|
1122
|
+
.state-pill[data-tone="working"] .state-dot { background: #E0912A; }
|
|
1123
|
+
.state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
|
|
1124
|
+
.state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
|
|
1125
|
+
|
|
1126
|
+
.net-panel {
|
|
1127
|
+
background: var(--soil-deep); color: var(--parchment);
|
|
1128
|
+
border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1129
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.3);
|
|
1130
|
+
padding: .7rem .8rem; margin-bottom: 1rem;
|
|
1131
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
|
|
1132
|
+
}
|
|
1133
|
+
.net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
|
1134
|
+
.net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
|
|
1135
|
+
.net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
|
|
1136
|
+
.net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
|
|
1137
|
+
.net-state[data-tone="live"] b { color: var(--lem-go-hi); }
|
|
1138
|
+
.net-state[data-tone="failed"] b { color: #FF8C70; }
|
|
1139
|
+
.net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
|
|
1140
|
+
.net-state span { opacity: .8; }
|
|
1141
|
+
.net-close { margin-left: auto; }
|
|
1142
|
+
.net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
|
|
1143
|
+
.net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
|
|
1144
|
+
.net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
|
|
1145
|
+
.net-field { display: flex; flex-direction: column; gap: .2rem; }
|
|
1146
|
+
.net-panel input[type="text"], .net-panel textarea {
|
|
1147
|
+
font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
|
|
1148
|
+
border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
|
|
1149
|
+
resize: vertical; overflow-wrap: anywhere;
|
|
1150
|
+
}
|
|
1151
|
+
.net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
|
|
1152
|
+
.net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
|
|
1153
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1154
|
+
.net-hint { margin: 0; font-size: .7rem; opacity: .75; }
|
|
1155
|
+
.net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
|
|
1156
|
+
.node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
|
|
1157
|
+
.node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
|
|
1158
|
+
.node-row[data-away="true"] { opacity: .55; }
|
|
1159
|
+
.node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
|
|
1160
|
+
color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
|
|
1161
|
+
align-items: center; justify-content: center; flex: 0 0 auto; }
|
|
1162
|
+
.node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1163
|
+
.node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
|
|
1164
|
+
.node-hand { opacity: 0; }
|
|
1165
|
+
.node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
|
|
1166
|
+
.node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
|
|
1167
|
+
|
|
1168
|
+
.join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
|
|
1169
|
+
background: rgba(8,8,42,.86); padding: 1rem; }
|
|
1170
|
+
/* Setting display at all outranks the browser's own [hidden] rule, so every
|
|
1171
|
+
block here that is hidden by attribute has to say so a second time. */
|
|
1172
|
+
.join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
|
|
1173
|
+
.join-inner { background: var(--soil-deep);
|
|
1174
|
+
color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
|
|
1175
|
+
box-shadow: 0 10px 26px rgba(0,0,0,.5);
|
|
1176
|
+
padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
|
|
1177
|
+
.join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
|
|
1178
|
+
.join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
|
|
1179
|
+
.join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
|
|
1180
|
+
.join-reply { display: flex; flex-direction: column; gap: .3rem; }
|
|
1181
|
+
.join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
|
|
1182
|
+
background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
|
|
1183
|
+
width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
|
|
1184
|
+
.join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
|
|
1185
|
+
text-transform: uppercase; letter-spacing: .08em; }
|
|
1186
|
+
.join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
|
|
1187
|
+
text-decoration: underline; padding-left: 0 !important; }
|
|
1188
|
+
|
|
1189
|
+
/* ---- a wave, and the node a character is played from ---- */
|
|
1190
|
+
.pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
|
|
1191
|
+
.pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
|
|
1192
|
+
.wave-btn { flex: 0 0 auto; }
|
|
1193
|
+
.sprite-card { position: relative; }
|
|
1194
|
+
.wave-hand { position: absolute; top: -0.55rem; right: -0.35rem; font-size: 1.1rem; line-height: 1;
|
|
1195
|
+
transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; pointer-events: none; }
|
|
1196
|
+
@keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
|
|
1197
|
+
.sprite-node {
|
|
1198
|
+
margin-top: 1px; max-width: 100%; box-sizing: border-box;
|
|
1199
|
+
font-family: ${MONO_STACK}; font-size: .34rem; line-height: 1.3; letter-spacing: .01em; text-align: center;
|
|
1200
|
+
color: var(--soil-mid); background: rgba(239,230,216,.7); border-radius: 2px; padding: 0 .2rem;
|
|
1201
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1202
|
+
}
|
|
1203
|
+
.pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
|
|
1204
|
+
.mud-window.remote .pane-controls button { display: none; }
|
|
1205
|
+
.mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
|
|
1206
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1207
|
+
.wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
|
|
1208
|
+
}
|
|
888
1209
|
`;
|
|
889
1210
|
|
|
890
1211
|
/** The inlined page script, as a plain function body handed to
|
|
@@ -918,9 +1239,37 @@ function pageScript() {
|
|
|
918
1239
|
const allRoomIds = ${allRoomIds.toString()};
|
|
919
1240
|
const renderMudEditorText = ${renderMudEditorText.toString()};
|
|
920
1241
|
const wordBeforeCursor = ${wordBeforeCursor.toString()};
|
|
1242
|
+
const provenanceStamps = ${provenanceStamps.toString()};
|
|
1243
|
+
const peerTerm = ${peerTerm.toString()};
|
|
1244
|
+
const claimantOf = ${claimantOf.toString()};
|
|
1245
|
+
const wavingCharacters = ${wavingCharacters.toString()};
|
|
1246
|
+
const nodeNameFor = ${nodeNameFor.toString()};
|
|
1247
|
+
const offerBlobIn = ${offerBlobIn.toString()};
|
|
1248
|
+
const wireStateLabel = ${wireStateLabel.toString()};
|
|
1249
|
+
const nodeRowsFor = ${nodeRowsFor.toString()};
|
|
1250
|
+
const nodeInitials = ${nodeInitials.toString()};
|
|
1251
|
+
const inviteLinkFor = ${inviteLinkFor.toString()};
|
|
1252
|
+
const inviteParamsFrom = ${inviteParamsFrom.toString()};
|
|
921
1253
|
|
|
922
1254
|
const el = (id) => document.getElementById(id);
|
|
923
|
-
|
|
1255
|
+
// Which of the shipped burrows is loaded. Every read of the world — the
|
|
1256
|
+
// session it is opened over, the roster it is cast from, the provenance
|
|
1257
|
+
// prefix edit mode filters on — goes through this one index, so switching
|
|
1258
|
+
// scenarios needs no second copy of any of them.
|
|
1259
|
+
let scenarioIndex = 0;
|
|
1260
|
+
const scenario = function () { return DATA.scenarios[scenarioIndex]; };
|
|
1261
|
+
const rosterOf = function (s) { return (s.characters || []).map(function (c) { return c.id; }); };
|
|
1262
|
+
// Where the survey starts laying rooms out: the burrow's own origin, the
|
|
1263
|
+
// same fact the dig mechanic measures its reach from. Falling back to the
|
|
1264
|
+
// shipped default only matters for a world that declares no origin at all,
|
|
1265
|
+
// and burrowGraph already copes by starting from whichever room sorts first.
|
|
1266
|
+
const rootRoomOf = function (s) {
|
|
1267
|
+
const facts = (s.worldPayload && s.worldPayload.facts) || [];
|
|
1268
|
+
const origin = facts.find(function (f) { return f.predicate === "mgx:is-origin" && f.object === "true"; });
|
|
1269
|
+
return origin ? origin.subject : DATA.rootRoom;
|
|
1270
|
+
};
|
|
1271
|
+
let roster = rosterOf(scenario());
|
|
1272
|
+
let rootRoom = rootRoomOf(scenario());
|
|
924
1273
|
let slots = [];
|
|
925
1274
|
const ACTOR_COLORS = ["var(--actor-a)", "var(--actor-b)", "var(--actor-c)", "var(--actor-d)"];
|
|
926
1275
|
const NPC_COLOR = "var(--chalk)";
|
|
@@ -956,6 +1305,15 @@ function pageScript() {
|
|
|
956
1305
|
const liveWait = function () { return wait(delayMs); };
|
|
957
1306
|
|
|
958
1307
|
let freshlyDugRoom = null;
|
|
1308
|
+
// Which animals another node has claimed, character -> that node's name.
|
|
1309
|
+
// Refreshed from the store on every redraw, which is also every turn, so a
|
|
1310
|
+
// claim that arrives from a peer takes effect on the next tick rather than
|
|
1311
|
+
// being cached until something else happens to look.
|
|
1312
|
+
let claimedElsewhere = {};
|
|
1313
|
+
// Whoever is waving right now, and the timer that lets a wave stop being
|
|
1314
|
+
// current. A wave is never retracted — it just stops being recent.
|
|
1315
|
+
let wavingNow = [];
|
|
1316
|
+
let waveRedrawTimer = null;
|
|
959
1317
|
// The last omniscient read, kept so the pounce can draw the predator's own
|
|
960
1318
|
// den after the engine has already moved the character out of every room.
|
|
961
1319
|
let lastSnapshot = null;
|
|
@@ -991,10 +1349,15 @@ function pageScript() {
|
|
|
991
1349
|
const bootAt = bootSeq;
|
|
992
1350
|
return serializeTick(async function () {
|
|
993
1351
|
if (bootAt !== bootSeq || finished[character]) return { outOfPlay: true };
|
|
1352
|
+
// An animal another node claimed is played there, not here. Its moves
|
|
1353
|
+
// still arrive as facts and it still draws, talks and can be waved at —
|
|
1354
|
+
// this page just never takes its turn for it.
|
|
1355
|
+
if (claimedElsewhere[character]) return { playedElsewhere: true };
|
|
994
1356
|
globalTurn += 1;
|
|
995
1357
|
const result = await session.windows[character].autoplayTick(globalTurn);
|
|
996
1358
|
if (!result.outOfPlay) turnsTaken[character] = (turnsTaken[character] || 0) + 1;
|
|
997
1359
|
afterEngineTurn(character, result);
|
|
1360
|
+
await broadcastLocalChange();
|
|
998
1361
|
return result;
|
|
999
1362
|
});
|
|
1000
1363
|
}
|
|
@@ -1089,7 +1452,7 @@ function pageScript() {
|
|
|
1089
1452
|
for (const action of result.actions || []) {
|
|
1090
1453
|
if (action.kind !== "ask" && action.kind !== "talk") continue;
|
|
1091
1454
|
const other = action.teller || action.target || action.object;
|
|
1092
|
-
noteSpeech(result.room, character, { text:
|
|
1455
|
+
if (action.text) noteSpeech(result.room, character, { text: action.text });
|
|
1093
1456
|
if (other && action.thing) noteSpeech(result.room, other, { thing: action.thing });
|
|
1094
1457
|
else if (other && action.text) noteSpeech(result.room, other, { text: action.text });
|
|
1095
1458
|
}
|
|
@@ -1146,13 +1509,42 @@ function pageScript() {
|
|
|
1146
1509
|
|
|
1147
1510
|
function sendCommand(character, line) {
|
|
1148
1511
|
appendChat(character, "u", line);
|
|
1149
|
-
|
|
1512
|
+
const owner = claimedElsewhere[character];
|
|
1513
|
+
if (owner) {
|
|
1514
|
+
const refusal = character + " is played from " + owner + ". You can watch it and wave at it, but its turns are theirs.";
|
|
1515
|
+
appendChat(character, "a", refusal);
|
|
1516
|
+
return Promise.resolve({ answer: refusal, end: false });
|
|
1517
|
+
}
|
|
1518
|
+
// A wave is a gesture, not a world command — the engine has no verb for it
|
|
1519
|
+
// and would answer with an honest miss. Both ways in (this typed line and
|
|
1520
|
+
// the pane's own hand button) write the same fact.
|
|
1521
|
+
if (/^\\/?wave$/i.test(line.trim())) {
|
|
1522
|
+
return waveAs(character).then(function (here) {
|
|
1523
|
+
appendChat(character, "a", here
|
|
1524
|
+
? "you wave. Everyone in the " + here + " sees it."
|
|
1525
|
+
: character + " is standing nowhere, so there is nobody to wave at.");
|
|
1526
|
+
return { answer: "", end: false };
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
return serializeTick(function () { return session.windows[character].turn(line); }).then(async function (res) {
|
|
1150
1530
|
appendChat(character, "a", res.answer);
|
|
1531
|
+
await broadcastLocalChange();
|
|
1151
1532
|
renderSoon();
|
|
1152
1533
|
return res;
|
|
1153
1534
|
});
|
|
1154
1535
|
}
|
|
1155
1536
|
|
|
1537
|
+
// A wave is an ordinary add-only fact in the shared world, so it needs no
|
|
1538
|
+
// network to work: one browser renders it exactly the way four do, and the
|
|
1539
|
+
// room broadcast below is the only part sharing adds.
|
|
1540
|
+
async function waveAs(character) {
|
|
1541
|
+
if (!session || finished[character]) return null;
|
|
1542
|
+
const here = await serializeTick(function () { return session.wave(character); });
|
|
1543
|
+
if (here) await broadcastLocalChange();
|
|
1544
|
+
renderSoon();
|
|
1545
|
+
return here;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1156
1548
|
// Wired as the pane is built. The stage is rebuilt on every boot, so a
|
|
1157
1549
|
// pane's listeners live and die with it; each one still reads its slot's
|
|
1158
1550
|
// character at click time, because the cast is bound after the markup.
|
|
@@ -1177,6 +1569,10 @@ function pageScript() {
|
|
|
1177
1569
|
const character = characterInSlot(slot);
|
|
1178
1570
|
if (character) ensureTicker(character).stepOnce();
|
|
1179
1571
|
});
|
|
1572
|
+
el(w + "-wave").addEventListener("click", function () {
|
|
1573
|
+
const character = characterInSlot(slot);
|
|
1574
|
+
if (character) sendCommand(character, "wave");
|
|
1575
|
+
});
|
|
1180
1576
|
|
|
1181
1577
|
const log = el(w + "-chatlog");
|
|
1182
1578
|
log.addEventListener("click", function (e) {
|
|
@@ -1317,17 +1713,33 @@ function pageScript() {
|
|
|
1317
1713
|
|
|
1318
1714
|
// A sprite card: adventure.html's own frame-plus-nameplate, carrying the one
|
|
1319
1715
|
// 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
|
-
|
|
1716
|
+
// room offers nothing on) and the subject's own mass under the name. The
|
|
1717
|
+
// last argument holds the two things only a shared burrow has: a hand over a
|
|
1718
|
+
// character waving right now, and the node another peer plays it from — one
|
|
1719
|
+
// more line under the name, in the same font, never a second label system.
|
|
1720
|
+
function spriteCardHtml(subject, label, inner, command, extraClass, mass, extra) {
|
|
1721
|
+
const opts = extra || {};
|
|
1322
1722
|
const action = command
|
|
1323
1723
|
? ' actionable" data-command="' + esc(command) + '" role="button" tabindex="0" title="' + esc(command) + '"'
|
|
1324
1724
|
: '" title="' + esc(label) + '"';
|
|
1325
|
-
return '<div class="sprite-card ' + (extraClass || "") +
|
|
1725
|
+
return '<div class="sprite-card ' + (extraClass || "") + (opts.waving ? " waving" : "") + action
|
|
1726
|
+
+ ' data-subject="' + esc(subject) + '">'
|
|
1727
|
+
+ (opts.waving ? '<div class="wave-hand" aria-hidden="true">\\u{1F44B}</div>' : "")
|
|
1326
1728
|
+ inner + '<div class="sprite-label">' + esc(label) + "</div>"
|
|
1729
|
+
+ (opts.node ? '<div class="sprite-node">via ' + esc(opts.node) + "</div>" : "")
|
|
1327
1730
|
+ (mass === null || mass === undefined ? "" : '<div class="sprite-mass">mass ' + esc(mass) + "</div>")
|
|
1328
1731
|
+ "</div>";
|
|
1329
1732
|
}
|
|
1330
1733
|
|
|
1734
|
+
// A character another node plays carries that node's name; one this node
|
|
1735
|
+
// plays, or one nobody has claimed, carries nothing extra.
|
|
1736
|
+
function nodeLabelFor(character) {
|
|
1737
|
+
return claimedElsewhere[character] || null;
|
|
1738
|
+
}
|
|
1739
|
+
function isWavingNow(character) {
|
|
1740
|
+
return wavingNow.indexOf(character) !== -1;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1331
1743
|
function renderRoomView(character, rows, state, here, roomIds, roomMates) {
|
|
1332
1744
|
const w = paneIdFor(character);
|
|
1333
1745
|
drawRoomBackdrop(el(w + "-canvas"), roomKindForRoom(rows, here), here);
|
|
@@ -1337,12 +1749,14 @@ function pageScript() {
|
|
|
1337
1749
|
character, character,
|
|
1338
1750
|
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(character), rows, character) + "</div>",
|
|
1339
1751
|
null, "self", massOf(state, character),
|
|
1752
|
+
{ waving: isWavingNow(character), node: nodeLabelFor(character) },
|
|
1340
1753
|
);
|
|
1341
1754
|
|
|
1342
1755
|
const others = roomMates.map(function (mate) {
|
|
1343
1756
|
return spriteCardHtml(mate, mate,
|
|
1344
1757
|
'<div class="sprite">' + spriteSvgFor(speciesOfCharacter(mate), rows, mate) + "</div>",
|
|
1345
|
-
commands[mate], "", massOf(state, mate)
|
|
1758
|
+
commands[mate], "", massOf(state, mate),
|
|
1759
|
+
{ waving: isWavingNow(mate), node: nodeLabelFor(mate) });
|
|
1346
1760
|
});
|
|
1347
1761
|
const wall = [];
|
|
1348
1762
|
for (const obj of mudRoomSceneObjects(rows, state, here, character)) {
|
|
@@ -1571,14 +1985,14 @@ function pageScript() {
|
|
|
1571
1985
|
|
|
1572
1986
|
function renderMinimap(character, rows, state, here) {
|
|
1573
1987
|
const visited = session.windows[character].visitedRoomIds();
|
|
1574
|
-
const graph = burrowGraph(state, visited,
|
|
1988
|
+
const graph = burrowGraph(state, visited, rootRoom);
|
|
1575
1989
|
el(paneIdFor(character) + "-minimap").innerHTML = burrowSvg(graph, {
|
|
1576
1990
|
compact: true, here: here, label: "the ground " + character + " has covered",
|
|
1577
1991
|
});
|
|
1578
1992
|
}
|
|
1579
1993
|
|
|
1580
1994
|
function renderWorldMap(rows, state, roomIds) {
|
|
1581
|
-
const graph = burrowGraph(state, roomIds,
|
|
1995
|
+
const graph = burrowGraph(state, roomIds, rootRoom);
|
|
1582
1996
|
const occupants = {};
|
|
1583
1997
|
for (const room of roomIds) {
|
|
1584
1998
|
const here = charactersInRoom(state, room, everyone());
|
|
@@ -1636,13 +2050,35 @@ function pageScript() {
|
|
|
1636
2050
|
const snap = await session.snapshot();
|
|
1637
2051
|
lastSnapshot = snap;
|
|
1638
2052
|
const roomIds = allRoomIds(snap.rows);
|
|
2053
|
+
const nowMs = Date.now();
|
|
2054
|
+
wavingNow = wavingCharacters(snap.rows, nowMs);
|
|
2055
|
+
// Every claimed animal, not just the ones this page cast: a character
|
|
2056
|
+
// another node plays walks into this room as an ordinary room-mate, and
|
|
2057
|
+
// its label has to name where it is played from just the same.
|
|
2058
|
+
claimedElsewhere = {};
|
|
2059
|
+
if (myPeerId) {
|
|
2060
|
+
const claimed = {};
|
|
2061
|
+
for (const row of snap.rows) if (row.predicate === "mgx:playedBy") claimed[row.subject] = true;
|
|
2062
|
+
for (const character of Object.keys(claimed)) {
|
|
2063
|
+
const owner = claimantOf(snap.rows, character);
|
|
2064
|
+
if (owner === null || peerTerm(owner) === peerTerm(myPeerId)) continue;
|
|
2065
|
+
claimedElsewhere[character] = nodeNameFor(snap.rows, owner);
|
|
2066
|
+
const ticker = tickers[character];
|
|
2067
|
+
if (ticker) ticker.pause();
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
1639
2070
|
for (const character of everyone()) {
|
|
1640
2071
|
const reason = window.tmctMud.outOfPlayReasonOf(snap.state, character);
|
|
1641
2072
|
if (reason) markOutOfPlay(character, reason, null);
|
|
1642
2073
|
}
|
|
1643
2074
|
renderWorldMap(snap.rows, snap.state, roomIds);
|
|
1644
2075
|
for (const character of cast) {
|
|
1645
|
-
|
|
2076
|
+
const pane = paneIdFor(character);
|
|
2077
|
+
el(pane + "-turn").textContent = "turn " + turnsFor(character);
|
|
2078
|
+
const owner = claimedElsewhere[character];
|
|
2079
|
+
el(pane).classList.toggle("remote", Boolean(owner));
|
|
2080
|
+
el(pane + "-node").hidden = !owner;
|
|
2081
|
+
el(pane + "-node").textContent = owner ? "played from " + owner : "";
|
|
1646
2082
|
if (finished[character]) continue;
|
|
1647
2083
|
const place = snap.state.placements.get(character);
|
|
1648
2084
|
const here = place ? place.object : null;
|
|
@@ -1655,6 +2091,12 @@ function pageScript() {
|
|
|
1655
2091
|
renderMinimap(character, snap.rows, snap.state, here);
|
|
1656
2092
|
}
|
|
1657
2093
|
freshlyDugRoom = null;
|
|
2094
|
+
// A wave carries no retraction, so nothing tells the page when one stops
|
|
2095
|
+
// being current — the recency window does, and only a redraw can notice it
|
|
2096
|
+
// has passed. One follow-up per second while any hand is up, and none at
|
|
2097
|
+
// all once they are all down.
|
|
2098
|
+
clearTimeout(waveRedrawTimer);
|
|
2099
|
+
if (wavingNow.length) waveRedrawTimer = setTimeout(renderSoon, 1000);
|
|
1658
2100
|
}
|
|
1659
2101
|
|
|
1660
2102
|
// ---- edit mode -----------------------------------------------------------
|
|
@@ -1675,14 +2117,14 @@ function pageScript() {
|
|
|
1675
2117
|
let editing = false;
|
|
1676
2118
|
|
|
1677
2119
|
function worldOnlyRows(rows) {
|
|
1678
|
-
const prefix = "world:" +
|
|
2120
|
+
const prefix = "world:" + scenario().worldPayload.name;
|
|
1679
2121
|
return (rows || []).filter(function (r) {
|
|
1680
2122
|
return typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0;
|
|
1681
2123
|
});
|
|
1682
2124
|
}
|
|
1683
2125
|
|
|
1684
2126
|
function renderEditMap() {
|
|
1685
|
-
const graph = burrowGraph(editState, allRoomIds(editRows),
|
|
2127
|
+
const graph = burrowGraph(editState, allRoomIds(editRows), rootRoom);
|
|
1686
2128
|
el("editMapBoard").innerHTML = graph.nodes.length
|
|
1687
2129
|
? burrowSvg(graph, { here: selectedRoomId, label: "every room this world writes" })
|
|
1688
2130
|
: '<span class="edit-empty">no rooms written yet</span>';
|
|
@@ -1799,6 +2241,7 @@ function pageScript() {
|
|
|
1799
2241
|
status.className = "edit-status pending";
|
|
1800
2242
|
status.textContent = "reading the burrow\\u2026";
|
|
1801
2243
|
const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
|
|
2244
|
+
await broadcastLocalChange();
|
|
1802
2245
|
const snap = await session.snapshot();
|
|
1803
2246
|
allStoreRows = snap.rows;
|
|
1804
2247
|
editRows = worldOnlyRows(snap.rows);
|
|
@@ -1883,6 +2326,433 @@ function pageScript() {
|
|
|
1883
2326
|
await renderAll();
|
|
1884
2327
|
}
|
|
1885
2328
|
|
|
2329
|
+
// ---- the shared burrow: nodes, the wire, and the two-paste handshake -----
|
|
2330
|
+
// Every piece of networking arrives from ./vendor/p2p.js, the site's own
|
|
2331
|
+
// shared P2P asset, imported the first time somebody actually asks to share
|
|
2332
|
+
// rather than at boot. The room owns signaling, the mesh and the merge; this
|
|
2333
|
+
// block owns what a person sees and clicks, and nothing else.
|
|
2334
|
+
const P2P_ASSET = "./vendor/p2p.js";
|
|
2335
|
+
const NODE_NAME_KEY = "tmct.mud.nodeName";
|
|
2336
|
+
const invite = inviteParamsFrom(window.location.search);
|
|
2337
|
+
|
|
2338
|
+
let p2p = null;
|
|
2339
|
+
let p2pLoad = null;
|
|
2340
|
+
let room = null;
|
|
2341
|
+
let myPeerId = null;
|
|
2342
|
+
let myDisplayName = "";
|
|
2343
|
+
let worldId = "";
|
|
2344
|
+
let worldName = "";
|
|
2345
|
+
let nodeClockTimer = null;
|
|
2346
|
+
|
|
2347
|
+
function loadP2p() {
|
|
2348
|
+
if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
|
|
2349
|
+
return p2pLoad;
|
|
2350
|
+
}
|
|
2351
|
+
window.tmctP2pLoad = loadP2p;
|
|
2352
|
+
|
|
2353
|
+
function readStoredNodeName() {
|
|
2354
|
+
try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
|
|
2355
|
+
}
|
|
2356
|
+
function writeStoredNodeName(name) {
|
|
2357
|
+
try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
async function ensureIdentity() {
|
|
2361
|
+
const mod = await loadP2p();
|
|
2362
|
+
if (!myPeerId) myPeerId = mod.generatePeerId();
|
|
2363
|
+
if (!myDisplayName) {
|
|
2364
|
+
myDisplayName = readStoredNodeName() || mod.generateDisplayName();
|
|
2365
|
+
el("nodeNameInput").value = myDisplayName;
|
|
2366
|
+
}
|
|
2367
|
+
if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
|
|
2368
|
+
if (!worldName) {
|
|
2369
|
+
worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
|
|
2370
|
+
el("worldNameInput").value = worldName;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
async function ensureRoom() {
|
|
2375
|
+
if (room) return room;
|
|
2376
|
+
const mod = await loadP2p();
|
|
2377
|
+
await ensureIdentity();
|
|
2378
|
+
if (!session && bootRun) await bootRun;
|
|
2379
|
+
if (!session) throw new Error("the burrow hasn't finished opening");
|
|
2380
|
+
// The one place this page decides what is worth replicating: whatever the
|
|
2381
|
+
// engine calls live world state, plus the P2P layer's own four predicates.
|
|
2382
|
+
// The check is handed over rather than imported, so the sync filter never
|
|
2383
|
+
// learns the world engine's private predicate names.
|
|
2384
|
+
const extraPredicates = (window.tmctMud.P2P_PREDICATES || []).slice();
|
|
2385
|
+
const isState = window.tmctMud.isMudStatePredicate;
|
|
2386
|
+
room = mod.createP2pRoom({
|
|
2387
|
+
memoryDir: session.memoryDir,
|
|
2388
|
+
myPeerId: myPeerId,
|
|
2389
|
+
myDisplayName: myDisplayName,
|
|
2390
|
+
worldId: worldId,
|
|
2391
|
+
worldName: worldName,
|
|
2392
|
+
transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
|
|
2393
|
+
syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
|
|
2394
|
+
});
|
|
2395
|
+
room.onStateChanged(function (state) {
|
|
2396
|
+
// The join card exists to produce one reply. Once the channel is open it
|
|
2397
|
+
// has done its job, and a full-screen card over a live burrow is just
|
|
2398
|
+
// something in the way.
|
|
2399
|
+
if (state === "connected") el("joinCard").hidden = true;
|
|
2400
|
+
renderWire();
|
|
2401
|
+
});
|
|
2402
|
+
room.onPeersChanged(function () { renderNodes(); renderWire(); });
|
|
2403
|
+
room.onFactsChanged(function () { renderNodes(); renderSoon(); });
|
|
2404
|
+
await room.start();
|
|
2405
|
+
// The burrow's name is written into the graph the moment the room starts,
|
|
2406
|
+
// and this version retracts nothing — so the field stops taking edits.
|
|
2407
|
+
el("worldNameInput").readOnly = true;
|
|
2408
|
+
window.tmctP2pRoom = room;
|
|
2409
|
+
await claimMyAnimals();
|
|
2410
|
+
renderWire();
|
|
2411
|
+
renderNodes();
|
|
2412
|
+
if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
|
|
2413
|
+
return room;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
// Every animal this page drives is claimed the moment the burrow is shared,
|
|
2417
|
+
// panes and npcs alike: two peers each running scripted turns for one mole
|
|
2418
|
+
// would be two hands on one lemming. The claim is add-only and the oldest
|
|
2419
|
+
// wins, so a page that loses one simply stops driving that animal and
|
|
2420
|
+
// watches somebody else play it instead.
|
|
2421
|
+
async function claimMyAnimals() {
|
|
2422
|
+
if (!session || !room) return;
|
|
2423
|
+
await serializeTick(function () { return session.claimCharacters(everyone(), myPeerId); });
|
|
2424
|
+
await broadcastLocalChange();
|
|
2425
|
+
renderSoon();
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// The room diffs the store against what it last saw and broadcasts whatever
|
|
2429
|
+
// changed. The page calls it after every local turn, wave, claim and edit —
|
|
2430
|
+
// a broadcast that fails is a dead channel, reported by its own close
|
|
2431
|
+
// handler, and must never fail the turn that happened to carry it.
|
|
2432
|
+
function broadcastLocalChange() {
|
|
2433
|
+
if (!room) return Promise.resolve({ broadcast: 0 });
|
|
2434
|
+
return room.afterLocalChange().catch(function () { return { broadcast: 0 }; });
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function dropRoom(note) {
|
|
2438
|
+
if (!room) return;
|
|
2439
|
+
room.close();
|
|
2440
|
+
room = null;
|
|
2441
|
+
window.tmctP2pRoom = null;
|
|
2442
|
+
clearInterval(nodeClockTimer);
|
|
2443
|
+
nodeClockTimer = null;
|
|
2444
|
+
el("shareLink").value = "";
|
|
2445
|
+
el("replyOut").value = "";
|
|
2446
|
+
el("worldNameInput").readOnly = false;
|
|
2447
|
+
renderWire();
|
|
2448
|
+
renderNodes();
|
|
2449
|
+
// Last, not first: renderWire restates the note for the state it has just
|
|
2450
|
+
// read, which for a room that is gone is the idle one ("this browser holds
|
|
2451
|
+
// the only copy"). WHY the room went is the thing worth saying, so it is
|
|
2452
|
+
// written over the top rather than under it.
|
|
2453
|
+
if (note) el("wireStateNote").textContent = note;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// The state machine is shared with chat.html and the words mostly are too.
|
|
2457
|
+
// Two of them are not: nothing is taught here and nothing is a node below,
|
|
2458
|
+
// so those two notes are said in this page's own terms rather than forking
|
|
2459
|
+
// the whole table for a burrow.
|
|
2460
|
+
const MUD_WIRE_NOTES = {
|
|
2461
|
+
idle: "this browser holds the only copy of this burrow.",
|
|
2462
|
+
connected: "what you dig from here reaches every node in the burrow, and theirs reaches you.",
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2465
|
+
function renderWire() {
|
|
2466
|
+
const state = room ? room.state : "idle";
|
|
2467
|
+
const label = wireStateLabel(state);
|
|
2468
|
+
const note = MUD_WIRE_NOTES[state] || label.note;
|
|
2469
|
+
const wire = el("wireState");
|
|
2470
|
+
wire.dataset.tone = label.tone;
|
|
2471
|
+
el("wireStateWord").textContent = label.word;
|
|
2472
|
+
el("wireStateNote").textContent = note;
|
|
2473
|
+
const pill = el("statePill");
|
|
2474
|
+
pill.dataset.tone = label.tone;
|
|
2475
|
+
el("statePillWord").textContent = label.pill;
|
|
2476
|
+
pill.title = note;
|
|
2477
|
+
el("sharePanel").hidden = !el("shareLink").value;
|
|
2478
|
+
// Once the channel is open the reply has been used; leaving "send this
|
|
2479
|
+
// back" on screen would be asking for something already done.
|
|
2480
|
+
el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function relativeWhen(at, nowMs) {
|
|
2484
|
+
if (at === null || at === undefined) return "\\u2014";
|
|
2485
|
+
const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
|
|
2486
|
+
if (seconds < 5) return "now";
|
|
2487
|
+
if (seconds < 60) return seconds + "s";
|
|
2488
|
+
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
2489
|
+
if (seconds < 86400) return Math.round(seconds / 3600) + "h";
|
|
2490
|
+
return Math.round(seconds / 86400) + "d";
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function renderNodes() {
|
|
2494
|
+
const listEl = el("nodeList");
|
|
2495
|
+
if (!room || !p2p) {
|
|
2496
|
+
el("nodeCount").textContent = "";
|
|
2497
|
+
listEl.textContent = "";
|
|
2498
|
+
el("nodeEmpty").hidden = false;
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
const rows = nodeRowsFor({
|
|
2502
|
+
peers: room.peers(),
|
|
2503
|
+
factRows: room.factRows(),
|
|
2504
|
+
myPeerId: myPeerId,
|
|
2505
|
+
myDisplayName: myDisplayName,
|
|
2506
|
+
nameFor: room.displayNameFor,
|
|
2507
|
+
latestTimestampOf: p2p.latestProvenanceTimestamp,
|
|
2508
|
+
});
|
|
2509
|
+
const nowMs = Date.now();
|
|
2510
|
+
const waving = wavingCharacters(room.factRows(), nowMs);
|
|
2511
|
+
el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
|
|
2512
|
+
listEl.textContent = "";
|
|
2513
|
+
for (const entry of rows) {
|
|
2514
|
+
const item = document.createElement("li");
|
|
2515
|
+
item.className = "node-row";
|
|
2516
|
+
item.dataset.self = String(entry.isSelf);
|
|
2517
|
+
item.dataset.away = String(!entry.connected);
|
|
2518
|
+
item.dataset.peer = entry.peerId;
|
|
2519
|
+
item.dataset.waving = String(waving.some(function (character) {
|
|
2520
|
+
const owner = claimantOf(room.factRows(), character);
|
|
2521
|
+
return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
|
|
2522
|
+
}));
|
|
2523
|
+
const avatar = document.createElement("span");
|
|
2524
|
+
avatar.className = "node-avatar";
|
|
2525
|
+
avatar.textContent = nodeInitials(entry.name);
|
|
2526
|
+
avatar.title = entry.connected
|
|
2527
|
+
? "connected"
|
|
2528
|
+
: "away \\u2014 closed the tab or dropped offline; everything it dug stays";
|
|
2529
|
+
const name = document.createElement("span");
|
|
2530
|
+
name.className = "node-name";
|
|
2531
|
+
name.textContent = entry.name;
|
|
2532
|
+
const hand = document.createElement("span");
|
|
2533
|
+
hand.className = "node-hand";
|
|
2534
|
+
hand.textContent = "\\u{1F44B}";
|
|
2535
|
+
const when = document.createElement("span");
|
|
2536
|
+
when.className = "node-when";
|
|
2537
|
+
when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
|
|
2538
|
+
item.appendChild(avatar);
|
|
2539
|
+
item.appendChild(name);
|
|
2540
|
+
if (entry.isSelf) {
|
|
2541
|
+
const self = document.createElement("span");
|
|
2542
|
+
self.className = "node-self";
|
|
2543
|
+
self.textContent = "you";
|
|
2544
|
+
item.appendChild(self);
|
|
2545
|
+
}
|
|
2546
|
+
item.appendChild(hand);
|
|
2547
|
+
item.appendChild(when);
|
|
2548
|
+
listEl.appendChild(item);
|
|
2549
|
+
}
|
|
2550
|
+
el("nodeEmpty").hidden = rows.length > 1;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
async function copyText(text) {
|
|
2554
|
+
try {
|
|
2555
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2556
|
+
await navigator.clipboard.writeText(text);
|
|
2557
|
+
return true;
|
|
2558
|
+
}
|
|
2559
|
+
} catch { /* fall through — the box on the page still holds the text */ }
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const openNetPanel = function () {
|
|
2564
|
+
el("netPanel").hidden = false;
|
|
2565
|
+
el("statePill").setAttribute("aria-expanded", "true");
|
|
2566
|
+
};
|
|
2567
|
+
const closeNetPanel = function () {
|
|
2568
|
+
el("netPanel").hidden = true;
|
|
2569
|
+
el("statePill").setAttribute("aria-expanded", "false");
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
function wireNetPanel() {
|
|
2573
|
+
el("netPanelClose").addEventListener("click", closeNetPanel);
|
|
2574
|
+
el("statePill").addEventListener("click", function () {
|
|
2575
|
+
if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
el("shareBtn").addEventListener("click", async function () {
|
|
2579
|
+
const shareBtn = el("shareBtn");
|
|
2580
|
+
shareBtn.disabled = true;
|
|
2581
|
+
try {
|
|
2582
|
+
const active = await ensureRoom();
|
|
2583
|
+
const minted = await active.startSharing();
|
|
2584
|
+
el("shareLink").value = inviteLinkFor(window.location.href, {
|
|
2585
|
+
blob: minted.blob, world: worldId, worldName: worldName,
|
|
2586
|
+
});
|
|
2587
|
+
el("replyProblem").hidden = true;
|
|
2588
|
+
openNetPanel();
|
|
2589
|
+
renderWire();
|
|
2590
|
+
await copyText(el("shareLink").value);
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
|
|
2593
|
+
el("replyProblem").hidden = false;
|
|
2594
|
+
openNetPanel();
|
|
2595
|
+
} finally {
|
|
2596
|
+
shareBtn.disabled = false;
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2600
|
+
// The inviter's page has exactly one paste target, so there is no wrong box
|
|
2601
|
+
// to choose. A rejected paste keeps its text so the copy can be fixed.
|
|
2602
|
+
el("replyBtn").addEventListener("click", async function () {
|
|
2603
|
+
let active = room;
|
|
2604
|
+
if (!active) {
|
|
2605
|
+
try { active = await ensureRoom(); } catch { return; }
|
|
2606
|
+
}
|
|
2607
|
+
const outcome = await active.completeInvite(el("replyBox").value);
|
|
2608
|
+
if (outcome && outcome.error) {
|
|
2609
|
+
el("replyProblem").textContent = outcome.message;
|
|
2610
|
+
el("replyProblem").hidden = false;
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
el("replyProblem").hidden = true;
|
|
2614
|
+
el("replyBox").value = "";
|
|
2615
|
+
renderWire();
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
|
|
2619
|
+
|
|
2620
|
+
// The link flow lands somebody on the join card before they have dug
|
|
2621
|
+
// anything. This is the other case: a burrow already open, an invite that
|
|
2622
|
+
// arrived in a message, and no reason to throw the burrow away to take it.
|
|
2623
|
+
el("joinOpenBtn").addEventListener("click", function () {
|
|
2624
|
+
el("invitePanel").hidden = false;
|
|
2625
|
+
openNetPanel();
|
|
2626
|
+
el("inviteBox").focus();
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
el("inviteBtn").addEventListener("click", async function () {
|
|
2630
|
+
const problem = el("inviteProblem");
|
|
2631
|
+
const pasted = el("inviteBox").value.trim();
|
|
2632
|
+
if (!pasted) {
|
|
2633
|
+
problem.textContent = "paste the invite you were sent, then try again";
|
|
2634
|
+
problem.hidden = false;
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (room) {
|
|
2638
|
+
problem.textContent = "this burrow is already shared. Reset it first, then take the invite.";
|
|
2639
|
+
problem.hidden = false;
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// A whole link and a bare blob are both what people actually paste, so
|
|
2643
|
+
// both are read. The blob's own envelope decides either way.
|
|
2644
|
+
const blob = offerBlobIn(pasted);
|
|
2645
|
+
const mod = await loadP2p();
|
|
2646
|
+
const decoded = mod.decodeInviteBlob(blob);
|
|
2647
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2648
|
+
problem.textContent = decoded.error
|
|
2649
|
+
? "this invite looks cut short \\u2014 ask for it to be sent again"
|
|
2650
|
+
: "that's a reply, not an invite \\u2014 it belongs on the page that sent the invite";
|
|
2651
|
+
problem.hidden = false;
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
// The world id has to be the invite's before the room is opened: a room
|
|
2655
|
+
// that minted its own would refuse the invite as belonging elsewhere.
|
|
2656
|
+
await ensureIdentity();
|
|
2657
|
+
worldId = decoded.value.world || worldId;
|
|
2658
|
+
if (decoded.value.worldName) {
|
|
2659
|
+
worldName = decoded.value.worldName;
|
|
2660
|
+
el("worldNameInput").value = worldName;
|
|
2661
|
+
}
|
|
2662
|
+
try {
|
|
2663
|
+
const active = await ensureRoom();
|
|
2664
|
+
const outcome = await active.acceptInvite(blob);
|
|
2665
|
+
if (outcome && outcome.error) {
|
|
2666
|
+
problem.textContent = outcome.message;
|
|
2667
|
+
problem.hidden = false;
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
problem.hidden = true;
|
|
2671
|
+
el("inviteBox").value = "";
|
|
2672
|
+
el("replyOut").value = outcome.blob;
|
|
2673
|
+
renderWire();
|
|
2674
|
+
await copyText(outcome.blob);
|
|
2675
|
+
} catch (err) {
|
|
2676
|
+
problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2677
|
+
problem.hidden = false;
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
el("nodeNameInput").addEventListener("change", function () {
|
|
2682
|
+
const name = el("nodeNameInput").value.trim();
|
|
2683
|
+
if (!name || name === myDisplayName) { el("nodeNameInput").value = myDisplayName; return; }
|
|
2684
|
+
myDisplayName = name;
|
|
2685
|
+
writeStoredNodeName(name);
|
|
2686
|
+
if (!room) { renderNodes(); return; }
|
|
2687
|
+
room.setMyDisplayName(name).then(renderNodes).catch(function () { /* the next broadcast carries it */ });
|
|
2688
|
+
});
|
|
2689
|
+
el("worldNameInput").addEventListener("change", function () {
|
|
2690
|
+
const input = el("worldNameInput");
|
|
2691
|
+
if (input.readOnly) { input.value = worldName; return; }
|
|
2692
|
+
const name = input.value.trim();
|
|
2693
|
+
if (name) worldName = name;
|
|
2694
|
+
input.value = worldName;
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
el("joinBtn").addEventListener("click", async function () {
|
|
2698
|
+
const joinBtn = el("joinBtn");
|
|
2699
|
+
joinBtn.disabled = true;
|
|
2700
|
+
try {
|
|
2701
|
+
const active = await ensureRoom();
|
|
2702
|
+
const outcome = await active.acceptInvite(invite.offer);
|
|
2703
|
+
if (outcome && outcome.error) {
|
|
2704
|
+
el("joinProblem").textContent = outcome.message;
|
|
2705
|
+
el("joinProblem").hidden = false;
|
|
2706
|
+
joinBtn.disabled = false;
|
|
2707
|
+
joinBtn.textContent = "try again";
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
el("joinProblem").hidden = true;
|
|
2711
|
+
el("joinReply").value = outcome.blob;
|
|
2712
|
+
el("replyOut").value = outcome.blob;
|
|
2713
|
+
el("joinReplyWrap").hidden = false;
|
|
2714
|
+
joinBtn.textContent = "reply created";
|
|
2715
|
+
el("joinDismiss").textContent = "close this and start digging";
|
|
2716
|
+
renderWire();
|
|
2717
|
+
await copyText(outcome.blob);
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2720
|
+
el("joinProblem").hidden = false;
|
|
2721
|
+
joinBtn.disabled = false;
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
|
|
2725
|
+
el("joinDismiss").addEventListener("click", function () {
|
|
2726
|
+
el("joinCard").hidden = true;
|
|
2727
|
+
if (el("replyOut").value) openNetPanel();
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// A link that lost part of itself on the way reads as a mangled link, never
|
|
2732
|
+
// as no invite at all.
|
|
2733
|
+
async function prepareJoinCard() {
|
|
2734
|
+
el("joinCard").hidden = false;
|
|
2735
|
+
el("joinWorld").textContent = invite.worldName || "a shared burrow";
|
|
2736
|
+
el("joinBtn").focus();
|
|
2737
|
+
const mod = await loadP2p();
|
|
2738
|
+
const decoded = mod.decodeInviteBlob(invite.offer);
|
|
2739
|
+
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2740
|
+
el("joinBtn").hidden = true;
|
|
2741
|
+
el("joinEyebrow").textContent = "this link didn't arrive in one piece";
|
|
2742
|
+
el("joinWorld").textContent = invite.worldName || "an invitation";
|
|
2743
|
+
el("joinBody").textContent = decoded.error
|
|
2744
|
+
? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
|
|
2745
|
+
: "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
|
|
2746
|
+
el("joinDismiss").textContent = "dig on your own instead";
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (decoded.value.world) invite.world = decoded.value.world;
|
|
2750
|
+
if (decoded.value.worldName) {
|
|
2751
|
+
invite.worldName = decoded.value.worldName;
|
|
2752
|
+
el("joinWorld").textContent = decoded.value.worldName;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
1886
2756
|
// ---- the control deck ----------------------------------------------------
|
|
1887
2757
|
// The players slider runs on detents (1, 2, 4), so its own value is the
|
|
1888
2758
|
// index of the count, never the count.
|
|
@@ -1942,6 +2812,22 @@ function pageScript() {
|
|
|
1942
2812
|
npcCountSlider.addEventListener("input", function () { showNpcCount(chosenNpcCount()); });
|
|
1943
2813
|
npcCountSlider.addEventListener("change", function () { boot(); });
|
|
1944
2814
|
el("resetBtn").addEventListener("click", function () { boot(); });
|
|
2815
|
+
// Picking a burrow is a recast like any other, so it runs the same boot
|
|
2816
|
+
// the sliders and reset run. Edit mode follows the world rather than the
|
|
2817
|
+
// page: whatever is half-typed is flushed into the burrow it was typed
|
|
2818
|
+
// over, and the editor reopens on the one that just loaded.
|
|
2819
|
+
const scenarioSelect = el("scenarioSelect");
|
|
2820
|
+
if (scenarioSelect) {
|
|
2821
|
+
scenarioSelect.addEventListener("change", async function () {
|
|
2822
|
+
const picked = Number(scenarioSelect.value);
|
|
2823
|
+
if (!DATA.scenarios[picked] || picked === scenarioIndex) return;
|
|
2824
|
+
const wasEditing = editing;
|
|
2825
|
+
if (wasEditing) await exitEditMode();
|
|
2826
|
+
scenarioIndex = picked;
|
|
2827
|
+
await boot("a different burrow opened \\u2014 share again to link up.");
|
|
2828
|
+
if (wasEditing) await enterEditMode();
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
1945
2831
|
el("editModeBtn").addEventListener("click", function () {
|
|
1946
2832
|
if (!session) return;
|
|
1947
2833
|
if (editing) exitEditMode(); else enterEditMode();
|
|
@@ -1996,9 +2882,24 @@ function pageScript() {
|
|
|
1996
2882
|
// session on the floor rather than binding it to the stage the newer boot
|
|
1997
2883
|
// has already drawn.
|
|
1998
2884
|
let bootSeq = 0;
|
|
1999
|
-
|
|
2885
|
+
let bootRun = null;
|
|
2886
|
+
function boot(note) {
|
|
2887
|
+
bootRun = openWorld(note);
|
|
2888
|
+
return bootRun;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
async function openWorld(note) {
|
|
2000
2892
|
const seq = bootSeq += 1;
|
|
2001
2893
|
autoOn = false;
|
|
2894
|
+
// A room is bound to the store it was opened over, and recasting mints a
|
|
2895
|
+
// brand new one — so a burrow that restarts leaves the link behind rather
|
|
2896
|
+
// than quietly syncing a world nobody else is in. Saying so is the whole
|
|
2897
|
+
// handling; sharing again opens a fresh link over the new world. Picking a
|
|
2898
|
+
// different burrow is the same drop for the same reason, and says so in
|
|
2899
|
+
// its own words.
|
|
2900
|
+
dropRoom(note || "the burrow restarted \\u2014 share again to link up.");
|
|
2901
|
+
claimedElsewhere = {};
|
|
2902
|
+
wavingNow = [];
|
|
2002
2903
|
const playBtn = el("autoToggle");
|
|
2003
2904
|
playBtn.setAttribute("aria-pressed", "false");
|
|
2004
2905
|
playBtn.textContent = "\\u25B6 play";
|
|
@@ -2023,6 +2924,10 @@ function pageScript() {
|
|
|
2023
2924
|
// animal it has and no empty pane. The npcs draw from what the panes left,
|
|
2024
2925
|
// out of a roster grown to hold everyone, so no animal is ever both.
|
|
2025
2926
|
const npcCount = chosenNpcCount();
|
|
2927
|
+
// Read fresh, never cached at load: each burrow authors its own animals,
|
|
2928
|
+
// so the roster belongs to the scenario rather than to the page.
|
|
2929
|
+
roster = rosterOf(scenario());
|
|
2930
|
+
rootRoom = rootRoomOf(scenario());
|
|
2026
2931
|
cast = window.tmctMud.pickMudRoster(roster, { count: chosenPlayerCount() });
|
|
2027
2932
|
const pool = window.tmctMud.expandMudRoster(roster, cast.length + npcCount)
|
|
2028
2933
|
.filter(function (id) { return cast.indexOf(id) === -1; });
|
|
@@ -2031,7 +2936,7 @@ function pageScript() {
|
|
|
2031
2936
|
showPlayerCount(cast.length);
|
|
2032
2937
|
showNpcCount(npcs.length);
|
|
2033
2938
|
renderStage();
|
|
2034
|
-
const opened = await window.tmctMud.createMudSession(
|
|
2939
|
+
const opened = await window.tmctMud.createMudSession(scenario().worldPayload, { characters: everyone() });
|
|
2035
2940
|
if (seq !== bootSeq) return;
|
|
2036
2941
|
session = opened;
|
|
2037
2942
|
for (let i = 0; i < cast.length; i += 1) slotOf[cast[i]] = slots[i];
|
|
@@ -2050,6 +2955,20 @@ function pageScript() {
|
|
|
2050
2955
|
}
|
|
2051
2956
|
|
|
2052
2957
|
wireDeck();
|
|
2958
|
+
wireNetPanel();
|
|
2959
|
+
renderWire();
|
|
2960
|
+
renderNodes();
|
|
2053
2961
|
boot();
|
|
2962
|
+
// Off the boot path on purpose: this fetches the shared P2P asset so the
|
|
2963
|
+
// panel can show a real node name and burrow name before anyone clicks
|
|
2964
|
+
// anything. A failure here costs sharing, never the digging.
|
|
2965
|
+
ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
|
|
2966
|
+
if (invite) {
|
|
2967
|
+
prepareJoinCard().catch(function (err) {
|
|
2968
|
+
el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
|
|
2969
|
+
el("joinProblem").hidden = false;
|
|
2970
|
+
el("joinBtn").hidden = true;
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2054
2973
|
})();`;
|
|
2055
2974
|
}
|