@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -18
- package/bin/tmct.mjs +3 -0
- package/data/templates/responses.jsonl +3 -0
- package/package.json +2 -1
- package/src/adapters/memory/core.mjs +1358 -196
- package/src/adapters/memory/inspect.mjs +11 -0
- package/src/adapters/memory/shacl.mjs +38 -0
- package/src/adapters/p2p/webrtc-transport.mjs +28 -5
- package/src/domain/ask-vocab.mjs +39 -0
- package/src/domain/ask.mjs +183 -34
- package/src/domain/grammar/assert.mjs +8 -2
- package/src/domain/hanoi-board.mjs +232 -0
- package/src/domain/ingest-facts.mjs +120 -0
- package/src/domain/interpret/normalize.mjs +49 -0
- package/src/domain/memory/compaction.mjs +284 -0
- package/src/domain/memory/resolution.mjs +171 -0
- package/src/domain/memory/trust.mjs +175 -5
- package/src/domain/memory-facts.mjs +139 -0
- package/src/domain/p2p/facts.mjs +21 -0
- package/src/domain/p2p/peer-id.mjs +15 -0
- package/src/domain/p2p/provenance-relabel.mjs +13 -2
- package/src/domain/p2p/sync-filter.mjs +5 -1
- package/src/domain/p2p/wire.mjs +7 -4
- package/src/domain/scene-compose.mjs +2 -2
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/services/adventure-viz.mjs +5 -1
- package/src/services/adventure.mjs +70 -44
- package/src/services/chat-page-viz.mjs +381 -310
- package/src/services/chat.mjs +273 -155
- package/src/services/code-explorer-viz.mjs +141 -54
- package/src/services/index.mjs +1 -1
- package/src/services/ingest-viz.mjs +134 -9
- package/src/services/ledger-viz.mjs +7 -4
- package/src/services/memory-panel-viz.mjs +8 -3
- package/src/services/mud-turn.mjs +11 -8
- package/src/services/mud-viz.mjs +441 -206
- package/src/services/p2p-room.mjs +110 -23
- package/src/services/plan-viz.mjs +63 -4
- package/src/services/research-viz.mjs +18 -7
- package/src/services/share-overlay-viz.mjs +623 -0
- package/src/services/spider-fly-viz.mjs +2 -2
- package/src/services/sprite-catalog-viz.mjs +303 -78
- package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
- package/src/surfaces/web/chat-browser-entry.mjs +37 -10
- package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
- package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
- package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
- package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
- package/src/surfaces/web/mud-browser-entry.mjs +38 -7
- package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
- package/src/surfaces/web/plan-browser-entry.mjs +33 -2
- package/src/surfaces/web/research-browser-entry.mjs +11 -19
- package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
- package/src/surfaces/web/tmct-surface.mjs +12 -0
- package/src/surfaces/web/turn-session.mjs +10 -3
package/src/services/mud-viz.mjs
CHANGED
|
@@ -86,8 +86,13 @@ import {
|
|
|
86
86
|
} from "./adventure-viz.mjs";
|
|
87
87
|
import { renderMudEditorText } from "./mud-editor.mjs";
|
|
88
88
|
import {
|
|
89
|
-
wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
|
|
89
|
+
wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom, tapeRowFor, tapeClock,
|
|
90
90
|
} from "./chat-page-viz.mjs";
|
|
91
|
+
import {
|
|
92
|
+
SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
|
|
93
|
+
shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
|
|
94
|
+
} from "./share-overlay-viz.mjs";
|
|
95
|
+
export { offerBlobIn, peerTerm };
|
|
91
96
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
92
97
|
import { fnv1a32 } from "../domain/hash.mjs";
|
|
93
98
|
import { predatorSubjects } from "../domain/mud-facts.mjs";
|
|
@@ -123,6 +128,22 @@ const MUD_NOTE_LINES = [
|
|
|
123
128
|
"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.",
|
|
124
129
|
];
|
|
125
130
|
|
|
131
|
+
// The shared sharing overlay, in this page's own words — same markup, same
|
|
132
|
+
// ids, same ladder as chat.html's; only the vocabulary is a burrow's.
|
|
133
|
+
const MUD_SHARE_COPY = {
|
|
134
|
+
thing: "burrow",
|
|
135
|
+
lede: "This burrow lives in your browser and nowhere else. Sharing it opens a direct line (WebRTC) to one other browser: you send an invite, they send a reply, you paste it in. No account, no server, no copy anywhere in between.",
|
|
136
|
+
worldLabel: "the burrow you are sharing",
|
|
137
|
+
nodeLabel: "your node name",
|
|
138
|
+
namesNote: "both names are facts in the burrow, not settings.",
|
|
139
|
+
invitedEyebrow: "you have been invited to dig in",
|
|
140
|
+
joinBody: "Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.",
|
|
141
|
+
dismiss: "dig on your own instead",
|
|
142
|
+
rosterTitle: "who digs this burrow",
|
|
143
|
+
nodeEmpty: "nobody else is digging here yet.",
|
|
144
|
+
idleNote: "this browser holds the only copy of this burrow.",
|
|
145
|
+
};
|
|
146
|
+
|
|
126
147
|
/** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
|
|
127
148
|
* "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
|
|
128
149
|
* contained, `.toString()`-splice safe. */
|
|
@@ -242,15 +263,6 @@ export function provenanceStamps(provenance) {
|
|
|
242
263
|
return stamps;
|
|
243
264
|
}
|
|
244
265
|
|
|
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
266
|
/** Which peer plays `character`, or null when nobody has claimed it. Claims are
|
|
255
267
|
* add-only and never retracted, so two peers claiming the same animal both
|
|
256
268
|
* write and every page settles it the same way: the OLDEST claim wins, with
|
|
@@ -290,19 +302,6 @@ export function wavingCharacters(rows, nowMs, windowMs = 8000) {
|
|
|
290
302
|
return waving;
|
|
291
303
|
}
|
|
292
304
|
|
|
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
305
|
/** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
|
|
307
306
|
* falling back to a shortened peer id. A label never waits for a name to
|
|
308
307
|
* arrive. Pure, self-contained. */
|
|
@@ -386,11 +385,23 @@ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+x
|
|
|
386
385
|
<style>
|
|
387
386
|
${THEME_TOKENS_CSS}
|
|
388
387
|
${MUD_STYLE}
|
|
388
|
+
${SHARE_OVERLAY_CSS}
|
|
389
|
+
${MUD_SHARE_SKIN}
|
|
389
390
|
</style>
|
|
390
391
|
</head>
|
|
391
392
|
<body>
|
|
392
393
|
<main>
|
|
393
|
-
<
|
|
394
|
+
<header class="mud-topbar">
|
|
395
|
+
<h1 class="eyebrow">tmct · mud</h1>
|
|
396
|
+
<div class="mud-topbar-actions">
|
|
397
|
+
<button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
|
|
398
|
+
title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
|
|
399
|
+
<button type="button" id="shareBtn">share</button>
|
|
400
|
+
<button type="button" id="joinOpenBtn">join</button>
|
|
401
|
+
<a class="mud-topbar-help" href="./help.html#sharing" target="_blank" rel="noopener"
|
|
402
|
+
title="how sharing works, in a new tab" aria-label="how sharing works, opens in a new tab">?</a>
|
|
403
|
+
</div>
|
|
404
|
+
</header>
|
|
394
405
|
<div class="deck-row">
|
|
395
406
|
<section class="deck" aria-label="simulation controls">
|
|
396
407
|
<div class="deck-controls">
|
|
@@ -400,10 +411,6 @@ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-sel
|
|
|
400
411
|
${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
|
|
401
412
|
</select>` : ""}
|
|
402
413
|
<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>
|
|
407
414
|
<button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
|
|
408
415
|
<span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
|
|
409
416
|
</div>
|
|
@@ -448,61 +455,7 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
|
|
|
448
455
|
<div class="world-map-key" id="worldMapKey"></div>
|
|
449
456
|
</section>
|
|
450
457
|
</div>
|
|
451
|
-
|
|
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>
|
|
458
|
+
${shareOverlayHtml({ copy: MUD_SHARE_COPY, withTape: true })}
|
|
506
459
|
<div class="mud-stage" id="mudStage" data-panes="${openingCount}">
|
|
507
460
|
${paneHtml}
|
|
508
461
|
</div>
|
|
@@ -633,6 +586,22 @@ const MUD_STYLE = `
|
|
|
633
586
|
.mono { font-family: ${MONO_STACK}; }
|
|
634
587
|
main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
|
|
635
588
|
.eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
|
|
589
|
+
|
|
590
|
+
/* ---- the header: brand on the left, the sharing chrome on the right ----
|
|
591
|
+
The same arrangement chat.html's topbar holds — sharing is page chrome,
|
|
592
|
+
not a simulation control, so it lives above the deck rather than in it. */
|
|
593
|
+
.mud-topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: 0 0 .8rem; }
|
|
594
|
+
.mud-topbar .eyebrow { margin: 0; }
|
|
595
|
+
.mud-topbar-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
596
|
+
.mud-topbar-actions button:not(.state-pill), .mud-topbar-help {
|
|
597
|
+
background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
598
|
+
padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0, 0, 0, .12); text-decoration: none; font-size: .82rem;
|
|
599
|
+
}
|
|
600
|
+
.mud-topbar-actions button:not(.state-pill):hover, .mud-topbar-help:hover { background: var(--lem-face-hi); border-color: var(--burrow-glow); }
|
|
601
|
+
.mud-topbar-help { padding: .32rem .55rem; line-height: 1.2; }
|
|
602
|
+
/* the pill wears the deck's lit-readout idiom: this is the one word that
|
|
603
|
+
says whether the burrow is shared, and it reads like an instrument. */
|
|
604
|
+
.mud-topbar-actions .state-pill { background: var(--lem-readout-bg); color: var(--lem-readout); border: 1px solid #000; border-radius: 2px; padding: .3rem .6rem; cursor: pointer; }
|
|
636
605
|
h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
|
|
637
606
|
h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
|
|
638
607
|
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
@@ -1039,16 +1008,15 @@ const MUD_STYLE = `
|
|
|
1039
1008
|
}
|
|
1040
1009
|
.deck-slider, .deck h3 { color: var(--lem-chalk); }
|
|
1041
1010
|
.deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
|
|
1042
|
-
.deck button, .pane-controls button
|
|
1011
|
+
.deck button, .pane-controls button {
|
|
1043
1012
|
background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
|
|
1044
1013
|
padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
|
|
1045
1014
|
}
|
|
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) {
|
|
1015
|
+
.deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) {
|
|
1048
1016
|
background: var(--lem-face-hi); border-color: var(--burrow-glow);
|
|
1049
1017
|
}
|
|
1050
1018
|
.deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
|
|
1051
|
-
.
|
|
1019
|
+
.mud-topbar-actions button:active:not(:disabled) {
|
|
1052
1020
|
background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
|
|
1053
1021
|
}
|
|
1054
1022
|
.deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
|
|
@@ -1069,69 +1037,6 @@ const MUD_STYLE = `
|
|
|
1069
1037
|
.state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
|
|
1070
1038
|
.state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
|
|
1071
1039
|
|
|
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
1040
|
/* ---- a wave, and the node a character is played from ---- */
|
|
1136
1041
|
.pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
|
|
1137
1042
|
.pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
|
|
@@ -1149,8 +1054,32 @@ const MUD_STYLE = `
|
|
|
1149
1054
|
.pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
|
|
1150
1055
|
.mud-window.remote .pane-controls button { display: none; }
|
|
1151
1056
|
.mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
|
|
1057
|
+
|
|
1152
1058
|
@media (prefers-reduced-motion: reduce) {
|
|
1153
|
-
.wave-hand
|
|
1059
|
+
.wave-hand { animation: none; }
|
|
1060
|
+
}
|
|
1061
|
+
`;
|
|
1062
|
+
|
|
1063
|
+
// The shared overlay, re-pointed at the burrow's own palette: soil-deep card,
|
|
1064
|
+
// parchment ink, the burrow-glow as the working accent, moss as the live one.
|
|
1065
|
+
// Only variables change — every rule stays the component's.
|
|
1066
|
+
const MUD_SHARE_SKIN = `
|
|
1067
|
+
.shareOverlay {
|
|
1068
|
+
--so-scrim: rgba(10, 6, 2, .74);
|
|
1069
|
+
--so-card: var(--soil-deep);
|
|
1070
|
+
--so-ink: var(--parchment);
|
|
1071
|
+
--so-muted: var(--chalk);
|
|
1072
|
+
--so-line: var(--soil-light);
|
|
1073
|
+
--so-accent: var(--burrow-glow);
|
|
1074
|
+
--so-good: #9DB36A;
|
|
1075
|
+
--so-warn: var(--burrow-glow);
|
|
1076
|
+
--so-alert: #FF9C82;
|
|
1077
|
+
--so-good-soft: rgba(157, 179, 106, .18);
|
|
1078
|
+
--so-accent-soft: rgba(232, 163, 61, .14);
|
|
1079
|
+
--so-alert-soft: rgba(255, 156, 130, .14);
|
|
1080
|
+
--so-display: ${DISPLAY_STACK};
|
|
1081
|
+
--so-body: ${SANS_STACK};
|
|
1082
|
+
--so-mono: ${MONO_STACK};
|
|
1154
1083
|
}
|
|
1155
1084
|
`;
|
|
1156
1085
|
|
|
@@ -1204,10 +1133,20 @@ function pageScript() {
|
|
|
1204
1133
|
const nodeNameFor = ${nodeNameFor.toString()};
|
|
1205
1134
|
const offerBlobIn = ${offerBlobIn.toString()};
|
|
1206
1135
|
const wireStateLabel = ${wireStateLabel.toString()};
|
|
1136
|
+
const tapeRowFor = ${tapeRowFor.toString()};
|
|
1137
|
+
const tapeClock = ${tapeClock.toString()};
|
|
1207
1138
|
const nodeRowsFor = ${nodeRowsFor.toString()};
|
|
1208
1139
|
const nodeInitials = ${nodeInitials.toString()};
|
|
1209
1140
|
const inviteLinkFor = ${inviteLinkFor.toString()};
|
|
1210
1141
|
const inviteParamsFrom = ${inviteParamsFrom.toString()};
|
|
1142
|
+
const shareStepStates = ${shareStepStates.toString()};
|
|
1143
|
+
const activeWaves = ${activeWaves.toString()};
|
|
1144
|
+
const shareMessageFor = ${shareMessageFor.toString()};
|
|
1145
|
+
const replyMessageFor = ${replyMessageFor.toString()};
|
|
1146
|
+
const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
|
|
1147
|
+
const isProbablyMobile = ${isProbablyMobile.toString()};
|
|
1148
|
+
const copyText = ${copyTextToClipboard.toString()};
|
|
1149
|
+
const flashTip = ${flashCopyTip.toString()};
|
|
1211
1150
|
|
|
1212
1151
|
const el = (id) => document.getElementById(id);
|
|
1213
1152
|
// Which of the shipped burrows is loaded. Every read of the world — the
|
|
@@ -2201,19 +2140,44 @@ function pageScript() {
|
|
|
2201
2140
|
// block owns what a person sees and clicks, and nothing else.
|
|
2202
2141
|
const P2P_ASSET = "./vendor/p2p.js";
|
|
2203
2142
|
const NODE_NAME_KEY = "tmct.mud.nodeName";
|
|
2143
|
+
// Not the IndexedDB store beside it: that record is stamped with the site
|
|
2144
|
+
// version and the seed, and is dropped whenever either moves. A node id has
|
|
2145
|
+
// to outlive a deploy, because peers have already keyed this node's facts
|
|
2146
|
+
// on it.
|
|
2147
|
+
const NODE_ID_KEY = "tmct.mud.nodeId";
|
|
2204
2148
|
const invite = inviteParamsFrom(window.location.search);
|
|
2205
2149
|
|
|
2206
2150
|
let p2p = null;
|
|
2207
2151
|
let p2pLoad = null;
|
|
2208
2152
|
let room = null;
|
|
2209
2153
|
let myPeerId = null;
|
|
2154
|
+
let myNodeId = "";
|
|
2210
2155
|
let myDisplayName = "";
|
|
2211
2156
|
let worldId = "";
|
|
2212
2157
|
let worldName = "";
|
|
2213
2158
|
let nodeClockTimer = null;
|
|
2159
|
+
let mintedBlob = "";
|
|
2160
|
+
let nodeWaveTimer = null;
|
|
2161
|
+
let channelCount = 0;
|
|
2162
|
+
|
|
2163
|
+
// Which seat this page occupies in the handshake — "idle" until something
|
|
2164
|
+
// happens, "sponsor" once an invite is minted here, "joiner" once an invite
|
|
2165
|
+
// is opened here. The overlay's CSS reads it to decide which calls to
|
|
2166
|
+
// action stand at each step; entry distinguishes an invite that arrived as
|
|
2167
|
+
// a link (the hero card owns the reply) from one pasted in as text.
|
|
2168
|
+
function setOverlayRole(role, entry) {
|
|
2169
|
+
const overlay = el("netPanel");
|
|
2170
|
+
overlay.dataset.role = role;
|
|
2171
|
+
overlay.dataset.entry = entry || "";
|
|
2172
|
+
}
|
|
2214
2173
|
|
|
2215
2174
|
function loadP2p() {
|
|
2216
|
-
if (!p2pLoad)
|
|
2175
|
+
if (!p2pLoad) {
|
|
2176
|
+
p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
|
|
2177
|
+
p2pLoad.catch(function (err) {
|
|
2178
|
+
noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2217
2181
|
return p2pLoad;
|
|
2218
2182
|
}
|
|
2219
2183
|
window.tmctP2pLoad = loadP2p;
|
|
@@ -2228,6 +2192,13 @@ function pageScript() {
|
|
|
2228
2192
|
async function ensureIdentity() {
|
|
2229
2193
|
const mod = await loadP2p();
|
|
2230
2194
|
if (!myPeerId) myPeerId = mod.generatePeerId();
|
|
2195
|
+
if (!myNodeId) {
|
|
2196
|
+
try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
|
|
2197
|
+
if (!myNodeId) {
|
|
2198
|
+
myNodeId = mod.generateNodeId();
|
|
2199
|
+
try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2231
2202
|
if (!myDisplayName) {
|
|
2232
2203
|
myDisplayName = readStoredNodeName() || mod.generateDisplayName();
|
|
2233
2204
|
el("nodeNameInput").value = myDisplayName;
|
|
@@ -2239,6 +2210,35 @@ function pageScript() {
|
|
|
2239
2210
|
}
|
|
2240
2211
|
}
|
|
2241
2212
|
|
|
2213
|
+
// The one place a transport gets made, which makes it the one place every
|
|
2214
|
+
// message crossing one can be seen. The room asks for transports through
|
|
2215
|
+
// this factory and never learns it is being watched.
|
|
2216
|
+
function instrumentedTransport() {
|
|
2217
|
+
const transport = p2p.createTransport();
|
|
2218
|
+
const channel = "ch" + (++channelCount);
|
|
2219
|
+
transport.onMessage(function (message) { noteWire("in", message, channel); });
|
|
2220
|
+
transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
|
|
2221
|
+
transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
|
|
2222
|
+
return {
|
|
2223
|
+
createOffer: function () {
|
|
2224
|
+
return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
|
|
2225
|
+
},
|
|
2226
|
+
createAnswerFor: function (offerSdp) {
|
|
2227
|
+
return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
|
|
2228
|
+
},
|
|
2229
|
+
completeWithAnswer: function (answerSdp) {
|
|
2230
|
+
noteTape("note", "signal", "answer accepted", channel);
|
|
2231
|
+
return transport.completeWithAnswer(answerSdp);
|
|
2232
|
+
},
|
|
2233
|
+
send: function (message) { transport.send(message); noteWire("out", message, channel); },
|
|
2234
|
+
onMessage: function (fn) { transport.onMessage(fn); },
|
|
2235
|
+
onOpen: function (fn) { transport.onOpen(fn); },
|
|
2236
|
+
onClose: function (fn) { transport.onClose(fn); },
|
|
2237
|
+
close: function () { transport.close(); },
|
|
2238
|
+
get connectionState() { return transport.connectionState; },
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
2242
|
async function ensureRoom() {
|
|
2243
2243
|
if (room) return room;
|
|
2244
2244
|
const mod = await loadP2p();
|
|
@@ -2255,25 +2255,36 @@ function pageScript() {
|
|
|
2255
2255
|
memoryDir: session.memoryDir,
|
|
2256
2256
|
myPeerId: myPeerId,
|
|
2257
2257
|
myDisplayName: myDisplayName,
|
|
2258
|
+
myNodeId: myNodeId,
|
|
2258
2259
|
worldId: worldId,
|
|
2259
2260
|
worldName: worldName,
|
|
2260
|
-
transportFactory:
|
|
2261
|
+
transportFactory: instrumentedTransport,
|
|
2261
2262
|
syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
|
|
2262
2263
|
});
|
|
2263
2264
|
room.onStateChanged(function (state) {
|
|
2264
|
-
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
|
|
2265
|
+
noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
|
|
2266
|
+
// The overlay exists to make one connection. The moment the channel is
|
|
2267
|
+
// open its work is done — the lights come back up, and the pill in the
|
|
2268
|
+
// header is the way back in.
|
|
2269
|
+
if (state === "connected") {
|
|
2270
|
+
el("joinCard").hidden = true;
|
|
2271
|
+
closeNetPanel();
|
|
2272
|
+
flashTip(el("statePill"), "connected \\u2014 you're digging together");
|
|
2273
|
+
}
|
|
2268
2274
|
renderWire();
|
|
2269
2275
|
});
|
|
2270
2276
|
room.onPeersChanged(function () { renderNodes(); renderWire(); });
|
|
2271
|
-
room.onFactsChanged(function () {
|
|
2277
|
+
room.onFactsChanged(function (payload) {
|
|
2278
|
+
noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
|
|
2279
|
+
renderNodes();
|
|
2280
|
+
renderSoon();
|
|
2281
|
+
});
|
|
2272
2282
|
await room.start();
|
|
2273
2283
|
// The burrow's name is written into the graph the moment the room starts,
|
|
2274
2284
|
// and this version retracts nothing — so the field stops taking edits.
|
|
2275
2285
|
el("worldNameInput").readOnly = true;
|
|
2276
2286
|
window.tmctP2pRoom = room;
|
|
2287
|
+
noteTape("note", "link", "world " + worldName, myDisplayName);
|
|
2277
2288
|
await claimMyAnimals();
|
|
2278
2289
|
renderWire();
|
|
2279
2290
|
renderNodes();
|
|
@@ -2312,6 +2323,9 @@ function pageScript() {
|
|
|
2312
2323
|
el("shareLink").value = "";
|
|
2313
2324
|
el("replyOut").value = "";
|
|
2314
2325
|
el("worldNameInput").readOnly = false;
|
|
2326
|
+
mintedBlob = "";
|
|
2327
|
+
setOverlayRole("idle", "");
|
|
2328
|
+
noteTape("note", "link", "world closed", worldName);
|
|
2315
2329
|
renderWire();
|
|
2316
2330
|
renderNodes();
|
|
2317
2331
|
// Last, not first: renderWire restates the note for the state it has just
|
|
@@ -2321,6 +2335,86 @@ function pageScript() {
|
|
|
2321
2335
|
if (note) el("wireStateNote").textContent = note;
|
|
2322
2336
|
}
|
|
2323
2337
|
|
|
2338
|
+
// ---- the wire tape: every message, as it happens ------------------------
|
|
2339
|
+
const TAPE_CAP = 240;
|
|
2340
|
+
const TAPE_FAMILY_COLOR = {
|
|
2341
|
+
facts: "var(--so-good)",
|
|
2342
|
+
state: "var(--so-accent)",
|
|
2343
|
+
greeting: "var(--so-warn)",
|
|
2344
|
+
signal: "var(--so-warn)",
|
|
2345
|
+
fault: "var(--so-alert)",
|
|
2346
|
+
link: "var(--so-muted)",
|
|
2347
|
+
};
|
|
2348
|
+
const tapeCounts = new Map();
|
|
2349
|
+
let wireMessageCount = 0;
|
|
2350
|
+
|
|
2351
|
+
function pushTape(entry) {
|
|
2352
|
+
// The meter counts real wire messages only; the tape below shows those
|
|
2353
|
+
// plus the local notes (state changes, a channel opening) that explain
|
|
2354
|
+
// them. Folding notes into the counts would make "12 op" mean two things.
|
|
2355
|
+
if (entry.dir !== "note") {
|
|
2356
|
+
wireMessageCount += 1;
|
|
2357
|
+
tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
|
|
2358
|
+
}
|
|
2359
|
+
const tapeEl = el("tape");
|
|
2360
|
+
el("tapeEmpty").hidden = true;
|
|
2361
|
+
const row = document.createElement("li");
|
|
2362
|
+
row.className = "tape-row";
|
|
2363
|
+
row.dataset.dir = entry.dir;
|
|
2364
|
+
row.dataset.family = entry.family;
|
|
2365
|
+
row.dataset.type = entry.type;
|
|
2366
|
+
const bar = document.createElement("i");
|
|
2367
|
+
bar.className = "tape-bar";
|
|
2368
|
+
const clock = document.createElement("span");
|
|
2369
|
+
clock.className = "tape-clock";
|
|
2370
|
+
clock.textContent = tapeClock();
|
|
2371
|
+
const type = document.createElement("span");
|
|
2372
|
+
type.className = "tape-type";
|
|
2373
|
+
type.textContent = entry.type;
|
|
2374
|
+
const detail = document.createElement("span");
|
|
2375
|
+
detail.className = "tape-detail";
|
|
2376
|
+
detail.textContent = entry.detail || "";
|
|
2377
|
+
row.appendChild(bar);
|
|
2378
|
+
row.appendChild(clock);
|
|
2379
|
+
row.appendChild(type);
|
|
2380
|
+
row.appendChild(detail);
|
|
2381
|
+
tapeEl.insertBefore(row, tapeEl.firstChild);
|
|
2382
|
+
while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
|
|
2383
|
+
renderTapeMeter();
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function noteWire(direction, message, channel) {
|
|
2387
|
+
const row = tapeRowFor(direction, message);
|
|
2388
|
+
pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
|
|
2389
|
+
}
|
|
2390
|
+
function noteTape(dir, family, type, detail) {
|
|
2391
|
+
pushTape({ dir: dir, family: family, type: type, detail: detail });
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function renderTapeMeter() {
|
|
2395
|
+
const tapeMeterEl = el("tapeMeter");
|
|
2396
|
+
el("tapeTotal").textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
|
|
2397
|
+
tapeMeterEl.textContent = "";
|
|
2398
|
+
const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
|
|
2399
|
+
for (const pair of counted) {
|
|
2400
|
+
const item = document.createElement("span");
|
|
2401
|
+
item.className = "meter-item";
|
|
2402
|
+
item.dataset.type = pair[0];
|
|
2403
|
+
const bar = document.createElement("i");
|
|
2404
|
+
bar.className = "meter-bar";
|
|
2405
|
+
bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--so-muted)";
|
|
2406
|
+
const label = document.createElement("span");
|
|
2407
|
+
label.textContent = pair[0];
|
|
2408
|
+
const count = document.createElement("span");
|
|
2409
|
+
count.className = "meter-n";
|
|
2410
|
+
count.textContent = String(pair[1]);
|
|
2411
|
+
item.appendChild(bar);
|
|
2412
|
+
item.appendChild(label);
|
|
2413
|
+
item.appendChild(count);
|
|
2414
|
+
tapeMeterEl.appendChild(item);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2324
2418
|
// The state machine is shared with chat.html and the words mostly are too.
|
|
2325
2419
|
// Two of them are not: nothing is taught here and nothing is a node below,
|
|
2326
2420
|
// so those two notes are said in this page's own terms rather than forking
|
|
@@ -2342,10 +2436,29 @@ function pageScript() {
|
|
|
2342
2436
|
pill.dataset.tone = label.tone;
|
|
2343
2437
|
el("statePillWord").textContent = label.pill;
|
|
2344
2438
|
pill.title = note;
|
|
2345
|
-
el("
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2439
|
+
el("netPanel").dataset.tone = label.tone;
|
|
2440
|
+
renderSteps();
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function renderSteps() {
|
|
2444
|
+
const states = shareStepStates({
|
|
2445
|
+
role: el("netPanel").dataset.role,
|
|
2446
|
+
state: room ? room.state : "idle",
|
|
2447
|
+
hasReply: Boolean(el("replyOut").value),
|
|
2448
|
+
});
|
|
2449
|
+
for (const step of states) {
|
|
2450
|
+
const item = el("step-" + step.key);
|
|
2451
|
+
if (item) item.dataset.status = step.status;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// The scene's two browser cards: this one wears the node's own name, the
|
|
2456
|
+
// far one wears the first peer's the moment there is one to name.
|
|
2457
|
+
function renderScene() {
|
|
2458
|
+
el("sceneYouName").textContent = myDisplayName || "you";
|
|
2459
|
+
const peers = room ? room.peers() : [];
|
|
2460
|
+
const first = peers.find(function (p) { return p.connected; }) || peers[0];
|
|
2461
|
+
el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
|
|
2349
2462
|
}
|
|
2350
2463
|
|
|
2351
2464
|
function relativeWhen(at, nowMs) {
|
|
@@ -2358,12 +2471,27 @@ function pageScript() {
|
|
|
2358
2471
|
return Math.round(seconds / 86400) + "d";
|
|
2359
2472
|
}
|
|
2360
2473
|
|
|
2474
|
+
// A node is marked waving whether the wave came from one of its claimed
|
|
2475
|
+
// animals (a pane's own wave button) or from the node itself (the roster's
|
|
2476
|
+
// peer-scoped wave) — one hand, two ways to raise it.
|
|
2477
|
+
function wavingPeerTerms(nowMs) {
|
|
2478
|
+
const rows = room.factRows();
|
|
2479
|
+
const terms = new Set();
|
|
2480
|
+
for (const wave of activeWaves(rows, nowMs)) terms.add(wave.waver);
|
|
2481
|
+
for (const character of wavingCharacters(rows, nowMs)) {
|
|
2482
|
+
const owner = claimantOf(rows, character);
|
|
2483
|
+
if (owner !== null) terms.add(peerTerm(owner));
|
|
2484
|
+
}
|
|
2485
|
+
return terms;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2361
2488
|
function renderNodes() {
|
|
2362
2489
|
const listEl = el("nodeList");
|
|
2363
2490
|
if (!room || !p2p) {
|
|
2364
2491
|
el("nodeCount").textContent = "";
|
|
2365
2492
|
listEl.textContent = "";
|
|
2366
2493
|
el("nodeEmpty").hidden = false;
|
|
2494
|
+
renderScene();
|
|
2367
2495
|
return;
|
|
2368
2496
|
}
|
|
2369
2497
|
const rows = nodeRowsFor({
|
|
@@ -2375,7 +2503,7 @@ function pageScript() {
|
|
|
2375
2503
|
latestTimestampOf: p2p.latestProvenanceTimestamp,
|
|
2376
2504
|
});
|
|
2377
2505
|
const nowMs = Date.now();
|
|
2378
|
-
const waving =
|
|
2506
|
+
const waving = wavingPeerTerms(nowMs);
|
|
2379
2507
|
el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
|
|
2380
2508
|
listEl.textContent = "";
|
|
2381
2509
|
for (const entry of rows) {
|
|
@@ -2384,86 +2512,182 @@ function pageScript() {
|
|
|
2384
2512
|
item.dataset.self = String(entry.isSelf);
|
|
2385
2513
|
item.dataset.away = String(!entry.connected);
|
|
2386
2514
|
item.dataset.peer = entry.peerId;
|
|
2387
|
-
item.dataset.waving = String(waving.
|
|
2388
|
-
const owner = claimantOf(room.factRows(), character);
|
|
2389
|
-
return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
|
|
2390
|
-
}));
|
|
2515
|
+
item.dataset.waving = String(waving.has(peerTerm(entry.peerId)));
|
|
2391
2516
|
const avatar = document.createElement("span");
|
|
2392
2517
|
avatar.className = "node-avatar";
|
|
2393
2518
|
avatar.textContent = nodeInitials(entry.name);
|
|
2394
2519
|
avatar.title = entry.connected
|
|
2395
2520
|
? "connected"
|
|
2396
2521
|
: "away \\u2014 closed the tab or dropped offline; everything it dug stays";
|
|
2397
|
-
const
|
|
2398
|
-
|
|
2399
|
-
|
|
2522
|
+
const dot = document.createElement("i");
|
|
2523
|
+
dot.className = "node-dot";
|
|
2524
|
+
avatar.appendChild(dot);
|
|
2400
2525
|
const hand = document.createElement("span");
|
|
2401
2526
|
hand.className = "node-hand";
|
|
2402
2527
|
hand.textContent = "\\u{1F44B}";
|
|
2528
|
+
avatar.appendChild(hand);
|
|
2529
|
+
const name = document.createElement("span");
|
|
2530
|
+
name.className = "node-name";
|
|
2531
|
+
name.textContent = entry.name;
|
|
2403
2532
|
const when = document.createElement("span");
|
|
2404
2533
|
when.className = "node-when";
|
|
2405
2534
|
when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
|
|
2535
|
+
const fact = document.createElement("span");
|
|
2536
|
+
fact.className = "node-fact";
|
|
2537
|
+
if (entry.lastFact) {
|
|
2538
|
+
const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
|
|
2539
|
+
fact.textContent = line;
|
|
2540
|
+
fact.title = "last shared: " + line;
|
|
2541
|
+
} else {
|
|
2542
|
+
fact.textContent = "nothing shared yet";
|
|
2543
|
+
}
|
|
2544
|
+
const waveOne = document.createElement("button");
|
|
2545
|
+
waveOne.type = "button";
|
|
2546
|
+
waveOne.className = "node-wave-btn";
|
|
2547
|
+
waveOne.textContent = "\\u{1F44B}";
|
|
2548
|
+
waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " \\u2014 they see it as a fact arriving";
|
|
2549
|
+
waveOne.setAttribute("aria-label", waveOne.title);
|
|
2550
|
+
waveOne.addEventListener("click", function () { waveAtNode(entry.isSelf ? null : entry.peerId, waveOne); });
|
|
2406
2551
|
item.appendChild(avatar);
|
|
2407
2552
|
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
2553
|
item.appendChild(when);
|
|
2554
|
+
item.appendChild(waveOne);
|
|
2555
|
+
item.appendChild(fact);
|
|
2416
2556
|
listEl.appendChild(item);
|
|
2417
2557
|
}
|
|
2418
2558
|
el("nodeEmpty").hidden = rows.length > 1;
|
|
2559
|
+
renderScene();
|
|
2560
|
+
clearTimeout(nodeWaveTimer);
|
|
2561
|
+
if (waving.size) nodeWaveTimer = setTimeout(renderNodes, 1000);
|
|
2419
2562
|
}
|
|
2420
2563
|
|
|
2421
|
-
|
|
2564
|
+
// A node's own wave, distinct from an animal's: the fact's subject is the
|
|
2565
|
+
// peer, and its object names the audience — everyone, or one node.
|
|
2566
|
+
async function waveAtNode(targetPeerId, anchor) {
|
|
2422
2567
|
try {
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2568
|
+
const active = await ensureRoom();
|
|
2569
|
+
await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
|
|
2570
|
+
const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
|
|
2571
|
+
renderNodes();
|
|
2572
|
+
if (anchor) flashTip(anchor, "you waved at " + audience);
|
|
2573
|
+
} catch (err) {
|
|
2574
|
+
el("wireStateNote").textContent = "couldn't wave (" + (err && err.message ? err.message : err) + ").";
|
|
2575
|
+
}
|
|
2429
2576
|
}
|
|
2430
2577
|
|
|
2431
2578
|
const openNetPanel = function () {
|
|
2432
|
-
el("netPanel")
|
|
2579
|
+
const overlay = el("netPanel");
|
|
2580
|
+
overlay.hidden = false;
|
|
2433
2581
|
el("statePill").setAttribute("aria-expanded", "true");
|
|
2582
|
+
const card = overlay.querySelector(".so-card");
|
|
2583
|
+
if (card) card.focus({ preventScroll: true });
|
|
2434
2584
|
};
|
|
2435
2585
|
const closeNetPanel = function () {
|
|
2436
2586
|
el("netPanel").hidden = true;
|
|
2437
2587
|
el("statePill").setAttribute("aria-expanded", "false");
|
|
2438
2588
|
};
|
|
2439
2589
|
|
|
2590
|
+
function renderInviteShare() {
|
|
2591
|
+
const facts = room ? room.factRows().length : 0;
|
|
2592
|
+
const factsPart = facts ? " \\u00b7 " + facts + (facts === 1 ? " fact travels" : " facts travel") + " when they join" : "";
|
|
2593
|
+
el("inviteSummary").textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
|
|
2594
|
+
const message = shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" });
|
|
2595
|
+
el("waShareBtn").href = whatsAppShareUrl(message);
|
|
2596
|
+
el("webShareBtn").hidden = !navigator.share;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function renderReplyShare() {
|
|
2600
|
+
const message = replyMessageFor({ worldName: worldName, blob: el("replyOut").value });
|
|
2601
|
+
el("replyWaBtn").href = whatsAppShareUrl(message);
|
|
2602
|
+
el("joinWaBtn").href = whatsAppShareUrl(message);
|
|
2603
|
+
el("replyShareBtn").hidden = !navigator.share;
|
|
2604
|
+
el("joinShareBtn").hidden = !navigator.share;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
async function mintInvite(anchor) {
|
|
2608
|
+
try {
|
|
2609
|
+
const active = await ensureRoom();
|
|
2610
|
+
const minted = await active.startSharing();
|
|
2611
|
+
mintedBlob = minted.blob;
|
|
2612
|
+
el("shareLink").value = inviteLinkFor(window.location.href, {
|
|
2613
|
+
blob: minted.blob, world: worldId, worldName: worldName,
|
|
2614
|
+
});
|
|
2615
|
+
setOverlayRole("sponsor", "");
|
|
2616
|
+
renderInviteShare();
|
|
2617
|
+
el("replyProblem").hidden = true;
|
|
2618
|
+
openNetPanel();
|
|
2619
|
+
renderWire();
|
|
2620
|
+
await copyText(el("shareLink").value);
|
|
2621
|
+
flashTip(anchor, "link copied \\u2014 send it to one person");
|
|
2622
|
+
} catch (err) {
|
|
2623
|
+
el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
|
|
2624
|
+
el("replyProblem").hidden = false;
|
|
2625
|
+
openNetPanel();
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2440
2629
|
function wireNetPanel() {
|
|
2441
2630
|
el("netPanelClose").addEventListener("click", closeNetPanel);
|
|
2631
|
+
el("netPanel").addEventListener("click", function (e) {
|
|
2632
|
+
if (e.target === el("netPanel")) closeNetPanel();
|
|
2633
|
+
});
|
|
2634
|
+
document.addEventListener("keydown", function (e) {
|
|
2635
|
+
if (e.key === "Escape" && !el("netPanel").hidden) closeNetPanel();
|
|
2636
|
+
});
|
|
2442
2637
|
el("statePill").addEventListener("click", function () {
|
|
2443
2638
|
if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
|
|
2444
2639
|
});
|
|
2640
|
+
if (isProbablyMobile()) el("netPanel").classList.add("so-mobile");
|
|
2641
|
+
// The blobs are made to be copied whole — a click selects everything, so
|
|
2642
|
+
// a manual copy can never take half a code.
|
|
2643
|
+
for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
|
|
2644
|
+
el(boxId).addEventListener("focus", function (e) { e.target.select(); });
|
|
2645
|
+
}
|
|
2445
2646
|
|
|
2446
2647
|
el("shareBtn").addEventListener("click", async function () {
|
|
2447
2648
|
const shareBtn = el("shareBtn");
|
|
2448
2649
|
shareBtn.disabled = true;
|
|
2449
2650
|
try {
|
|
2450
|
-
|
|
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();
|
|
2651
|
+
await mintInvite(shareBtn);
|
|
2463
2652
|
} finally {
|
|
2464
2653
|
shareBtn.disabled = false;
|
|
2465
2654
|
}
|
|
2466
2655
|
});
|
|
2656
|
+
for (const mintId of ["mintInviteBtn", "remintBtn"]) {
|
|
2657
|
+
el(mintId).addEventListener("click", async function () {
|
|
2658
|
+
const btn = el(mintId);
|
|
2659
|
+
btn.disabled = true;
|
|
2660
|
+
try {
|
|
2661
|
+
await mintInvite(btn);
|
|
2662
|
+
} finally {
|
|
2663
|
+
btn.disabled = false;
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
el("copyLinkBtn").addEventListener("click", async function () {
|
|
2669
|
+
await copyText(el("shareLink").value);
|
|
2670
|
+
flashTip(el("copyLinkBtn"), "link copied \\u2014 send it to one person");
|
|
2671
|
+
});
|
|
2672
|
+
el("copyCodeBtn").addEventListener("click", async function () {
|
|
2673
|
+
await copyText(mintedBlob);
|
|
2674
|
+
flashTip(el("copyCodeBtn"), "code copied \\u2014 they paste it under step 3 on their page");
|
|
2675
|
+
});
|
|
2676
|
+
el("webShareBtn").addEventListener("click", function () {
|
|
2677
|
+
navigator.share({
|
|
2678
|
+
title: "join \\u201c" + worldName + "\\u201d",
|
|
2679
|
+
text: shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" }),
|
|
2680
|
+
}).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
|
|
2681
|
+
});
|
|
2682
|
+
const shareReplyViaSheet = function () {
|
|
2683
|
+
navigator.share({
|
|
2684
|
+
title: "my reply to \\u201c" + worldName + "\\u201d",
|
|
2685
|
+
text: replyMessageFor({ worldName: worldName, blob: el("replyOut").value }),
|
|
2686
|
+
}).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
|
|
2687
|
+
};
|
|
2688
|
+
el("replyShareBtn").addEventListener("click", shareReplyViaSheet);
|
|
2689
|
+
el("joinShareBtn").addEventListener("click", shareReplyViaSheet);
|
|
2690
|
+
el("waveAllBtn").addEventListener("click", function () { waveAtNode(null, el("waveAllBtn")); });
|
|
2467
2691
|
|
|
2468
2692
|
// The inviter's page has exactly one paste target, so there is no wrong box
|
|
2469
2693
|
// to choose. A rejected paste keeps its text so the copy can be fixed.
|
|
@@ -2483,15 +2707,18 @@ function pageScript() {
|
|
|
2483
2707
|
renderWire();
|
|
2484
2708
|
});
|
|
2485
2709
|
|
|
2486
|
-
el("copyReplyBtn").addEventListener("click", function () {
|
|
2710
|
+
el("copyReplyBtn").addEventListener("click", async function () {
|
|
2711
|
+
await copyText(el("replyOut").value);
|
|
2712
|
+
flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
|
|
2713
|
+
});
|
|
2487
2714
|
|
|
2488
2715
|
// The link flow lands somebody on the join card before they have dug
|
|
2489
2716
|
// anything. This is the other case: a burrow already open, an invite that
|
|
2490
2717
|
// arrived in a message, and no reason to throw the burrow away to take it.
|
|
2491
2718
|
el("joinOpenBtn").addEventListener("click", function () {
|
|
2492
|
-
el("invitePanel").hidden = false;
|
|
2493
2719
|
openNetPanel();
|
|
2494
|
-
el("inviteBox")
|
|
2720
|
+
const box = el("inviteBox");
|
|
2721
|
+
if (box.offsetParent !== null) box.focus();
|
|
2495
2722
|
});
|
|
2496
2723
|
|
|
2497
2724
|
el("inviteBtn").addEventListener("click", async function () {
|
|
@@ -2538,8 +2765,11 @@ function pageScript() {
|
|
|
2538
2765
|
problem.hidden = true;
|
|
2539
2766
|
el("inviteBox").value = "";
|
|
2540
2767
|
el("replyOut").value = outcome.blob;
|
|
2768
|
+
setOverlayRole("joiner", "paste");
|
|
2769
|
+
renderReplyShare();
|
|
2541
2770
|
renderWire();
|
|
2542
2771
|
await copyText(outcome.blob);
|
|
2772
|
+
flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
|
|
2543
2773
|
} catch (err) {
|
|
2544
2774
|
problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2545
2775
|
problem.hidden = false;
|
|
@@ -2581,26 +2811,31 @@ function pageScript() {
|
|
|
2581
2811
|
el("joinReplyWrap").hidden = false;
|
|
2582
2812
|
joinBtn.textContent = "reply created";
|
|
2583
2813
|
el("joinDismiss").textContent = "close this and start digging";
|
|
2814
|
+
renderReplyShare();
|
|
2584
2815
|
renderWire();
|
|
2585
2816
|
await copyText(outcome.blob);
|
|
2817
|
+
flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
|
|
2586
2818
|
} catch (err) {
|
|
2587
2819
|
el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2588
2820
|
el("joinProblem").hidden = false;
|
|
2589
2821
|
joinBtn.disabled = false;
|
|
2590
2822
|
}
|
|
2591
2823
|
});
|
|
2592
|
-
el("joinCopyBtn").addEventListener("click", function () {
|
|
2593
|
-
|
|
2594
|
-
el("
|
|
2595
|
-
if (el("replyOut").value) openNetPanel();
|
|
2824
|
+
el("joinCopyBtn").addEventListener("click", async function () {
|
|
2825
|
+
await copyText(el("joinReply").value);
|
|
2826
|
+
flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
|
|
2596
2827
|
});
|
|
2828
|
+
el("joinDismiss").addEventListener("click", closeNetPanel);
|
|
2597
2829
|
}
|
|
2598
2830
|
|
|
2599
2831
|
// A link that lost part of itself on the way reads as a mangled link, never
|
|
2600
2832
|
// as no invite at all.
|
|
2601
2833
|
async function prepareJoinCard() {
|
|
2834
|
+
setOverlayRole("joiner", "link");
|
|
2602
2835
|
el("joinCard").hidden = false;
|
|
2603
2836
|
el("joinWorld").textContent = invite.worldName || "a shared burrow";
|
|
2837
|
+
renderSteps();
|
|
2838
|
+
openNetPanel();
|
|
2604
2839
|
el("joinBtn").focus();
|
|
2605
2840
|
const mod = await loadP2p();
|
|
2606
2841
|
const decoded = mod.decodeInviteBlob(invite.offer);
|
|
@@ -2856,7 +3091,7 @@ function pageScript() {
|
|
|
2856
3091
|
// Off the boot path on purpose: this fetches the shared P2P asset so the
|
|
2857
3092
|
// panel can show a real node name and burrow name before anyone clicks
|
|
2858
3093
|
// anything. A failure here costs sharing, never the digging.
|
|
2859
|
-
ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
|
|
3094
|
+
ensureIdentity().then(renderNodes).catch(function () { /* sharing stays unavailable; the burrow does not */ });
|
|
2860
3095
|
if (invite) {
|
|
2861
3096
|
prepareJoinCard().catch(function (err) {
|
|
2862
3097
|
el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
|