@polycode-projects/the-mechanical-code-talker 5.0.5 → 5.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -19
- package/bin/tmct.mjs +63 -2
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +23 -0
- package/src/domain/ask-vocab.mjs +19 -0
- package/src/domain/ask.mjs +10 -5
- package/src/domain/codegraph.mjs +23 -9
- package/src/domain/game-config.mjs +12 -0
- package/src/domain/interpret/strategies/keywords.mjs +30 -1
- package/src/domain/memory/capability.mjs +15 -11
- package/src/domain/router/drive.mjs +36 -17
- package/src/domain/router/resolver.mjs +63 -17
- package/src/domain/spider-fly-world.mjs +2 -2
- package/src/domain/sprite-templates.mjs +19 -7
- package/src/domain/syllogise.mjs +16 -6
- package/src/domain/town-square-world.mjs +1 -1
- package/src/services/adventure-viz.mjs +5 -2
- package/src/services/adventure.mjs +8 -1
- package/src/services/chat-page-viz.mjs +123 -25
- package/src/services/chat-session.mjs +60 -10
- package/src/services/chat.mjs +328 -36
- package/src/services/code-explorer-viz.mjs +3 -2
- package/src/services/extract-facts.mjs +47 -7
- package/src/services/ingest-viz.mjs +113 -29
- package/src/services/ledger-viz.mjs +9 -4
- package/src/services/memory-panel-viz.mjs +44 -0
- package/src/services/mud-viz.mjs +21 -3
- package/src/services/mudiii-scene.mjs +407 -36
- package/src/services/mudiii-turn.mjs +65 -9
- package/src/services/mudiii-viz.mjs +810 -157
- package/src/services/p2p-room.mjs +1 -1
- package/src/services/plan-viz.mjs +26 -4
- package/src/services/predator-prey.mjs +141 -37
- package/src/services/research-viz.mjs +17 -23
- package/src/services/spider-fly-turn.mjs +7 -1
- package/src/services/spider-fly-viz.mjs +13 -5
- package/src/services/sprite-catalog-viz.mjs +3 -2
- package/src/services/viz-theme.mjs +20 -0
- package/src/services/viz-ticker.mjs +15 -2
- package/src/surfaces/http/server-http.mjs +90 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
- package/src/surfaces/web/mud-browser-entry.mjs +33 -1
- package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
- package/src/surfaces/web/tmct-surface.mjs +18 -6
- package/src/tools/handlers/tmct-ask.mjs +15 -2
- package/src/tools/server.mjs +31 -2
|
@@ -266,7 +266,7 @@ export function createP2pRoom({
|
|
|
266
266
|
// is a change worth broadcasting even though its provenance never moved.
|
|
267
267
|
let cachedRetractions = [];
|
|
268
268
|
const seenRetractionValueById = new Map();
|
|
269
|
-
const retractionDiffValue = (fact) => `${fact.provenance}
|
|
269
|
+
const retractionDiffValue = (fact) => `${fact.provenance}\u0000${fact.object}`;
|
|
270
270
|
|
|
271
271
|
// Store-touching work runs one job at a time, in arrival order. Every path
|
|
272
272
|
// that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// inline script degrades honestly when that sibling script is absent or
|
|
19
19
|
// fails to load — the live controls disable themselves rather than pretend
|
|
20
20
|
// to work.
|
|
21
|
-
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel } from "./viz-theme.mjs";
|
|
21
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
|
|
22
22
|
import { planToPddl } from "./plan-pddl.mjs";
|
|
23
23
|
|
|
24
24
|
const BOARD_W = 640;
|
|
@@ -334,6 +334,7 @@ button { font: inherit; }
|
|
|
334
334
|
/* ---- head strip: the nameplate ---- */
|
|
335
335
|
.headStrip { display: flex; justify-content: space-between; align-items: flex-end; gap: .6rem; flex-wrap: wrap; padding: .9rem 1.1rem .75rem; background: linear-gradient(180deg, var(--strip2), var(--strip)); border-bottom: 1px solid var(--line); border-radius: 9px 9px 0 0; }
|
|
336
336
|
.eyebrow { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin: 0 0 .35rem; }
|
|
337
|
+
${EYEBROW_LINKS_CSS}
|
|
337
338
|
h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0; color: var(--ink); }
|
|
338
339
|
.chip { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .02em; color: var(--muted); border: 1px solid var(--line); border-radius: 3px; padding: .22rem .6rem; background: var(--well); text-align: right; }
|
|
339
340
|
|
|
@@ -432,7 +433,7 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
|
|
|
432
433
|
<div class="rack">
|
|
433
434
|
<div class="headStrip">
|
|
434
435
|
<div>
|
|
435
|
-
<div class="eyebrow"
|
|
436
|
+
<div class="eyebrow">${demoEyebrowHtml("plan", "plan")}</div>
|
|
436
437
|
<h1 id="pageTitle">${escapeHtml(pageTitle)}</h1>
|
|
437
438
|
</div>
|
|
438
439
|
<span class="chip">blocks archetype · ${pageData.layouts.length} snapshots · plan: findActionPath</span>
|
|
@@ -529,6 +530,11 @@ const PLAN = ${embedded};
|
|
|
529
530
|
// server-rendered embed, for a plain renderPlanHtml() caller with no live
|
|
530
531
|
// bundle nearby).
|
|
531
532
|
let plan = null, N = 0, blockEls = {}, step = 0, playing = false, animating = false;
|
|
533
|
+
// The promise of whatever animateMove() call is currently in flight, or
|
|
534
|
+
// null between moves — reset() awaits this instead of declining outright
|
|
535
|
+
// when it is clicked mid-move, so a reset can never be silently dropped
|
|
536
|
+
// (see the reset handler below for why that mattered).
|
|
537
|
+
let animatingPromise = null;
|
|
532
538
|
|
|
533
539
|
const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
|
|
534
540
|
function drawState(i) {
|
|
@@ -608,7 +614,11 @@ const PLAN = ${embedded};
|
|
|
608
614
|
}
|
|
609
615
|
async function forward() {
|
|
610
616
|
if (animating || step >= N) return;
|
|
611
|
-
render();
|
|
617
|
+
render();
|
|
618
|
+
animatingPromise = animateMove(step);
|
|
619
|
+
await animatingPromise;
|
|
620
|
+
animatingPromise = null;
|
|
621
|
+
step += 1; render();
|
|
612
622
|
}
|
|
613
623
|
async function playRange(from, to) {
|
|
614
624
|
if (animating) return;
|
|
@@ -674,7 +684,19 @@ const PLAN = ${embedded};
|
|
|
674
684
|
|
|
675
685
|
btn.next.addEventListener("click", () => { playing = false; forward(); });
|
|
676
686
|
btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
|
|
677
|
-
|
|
687
|
+
// playing drops the instant reset is clicked, even mid-move — a click
|
|
688
|
+
// that landed while animating used to bail out before touching playing
|
|
689
|
+
// at all, which left playRange's own loop free to run one more step the
|
|
690
|
+
// moment the in-flight move settled, so a fast play-then-reset click could
|
|
691
|
+
// reset the board and have it immediately start moving again. Waiting on
|
|
692
|
+
// animatingPromise (rather than declining) means the reset itself still
|
|
693
|
+
// never fires while a move might be touching the same block elements, but
|
|
694
|
+
// it is never dropped either.
|
|
695
|
+
btn.reset.addEventListener("click", async () => {
|
|
696
|
+
playing = false;
|
|
697
|
+
if (animatingPromise) await animatingPromise;
|
|
698
|
+
step = 0; drawState(0); render();
|
|
699
|
+
});
|
|
678
700
|
btn.play.addEventListener("click", async () => {
|
|
679
701
|
if (animating) return;
|
|
680
702
|
if (playing) { playing = false; render(); return; }
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// which is board-size agnostic and models vision as plain Chebyshev distance
|
|
15
15
|
// with no line of sight — a building blocks movement, never sight.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// Four mechanics are OPT-IN, off unless `config` asks for them by name, so a
|
|
18
18
|
// cast that does not want them sees the same board it always did:
|
|
19
19
|
//
|
|
20
20
|
// - `carryPreyToWeb` splits catching from eating. A predator that shares a
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
// - `layEggs` adds the reproduction stage: an egg (mgx:laid-at-turn) laid in
|
|
28
28
|
// a web once mass crosses a threshold, hatching (mgx:hatched-into) into
|
|
29
29
|
// hatchlings that split the egg's mass.
|
|
30
|
+
// - `blendPreyDecision` replaces the prey's strict evade-then-forage order
|
|
31
|
+
// with one weighted score over both distances (`preyThreatWeight`), so a
|
|
32
|
+
// prey can take a crumb that costs it nothing. Off, prey abandon food the
|
|
33
|
+
// moment anything is in view. scripts/compare-prey-decision.mjs measures
|
|
34
|
+
// the two against each other.
|
|
30
35
|
|
|
31
36
|
import {
|
|
32
37
|
DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
|
|
@@ -47,13 +52,36 @@ import { objectClassChain, parseSnapshotSubject, snapshotSubject, worldEpochFact
|
|
|
47
52
|
export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
|
|
48
53
|
|
|
49
54
|
/** The v1 cast. Keyed by role, never by species — every knob this engine reads
|
|
50
|
-
* is role-keyed too, so swapping the pair is data.
|
|
55
|
+
* is role-keyed too, so swapping the pair is data. `hunts` is the cast's own
|
|
56
|
+
* statement of who preys on whom, and it is the only thing the decision chain
|
|
57
|
+
* reads to tell a threat from a bystander. */
|
|
51
58
|
export const MUDIII_ROLES = Object.freeze({
|
|
52
|
-
predator: { role: "predator", kind: "fox", idPrefix: "fox" },
|
|
53
|
-
prey: { role: "prey", kind: "goblin", idPrefix: "goblin" },
|
|
59
|
+
predator: { role: "predator", kind: "fox", idPrefix: "fox", hunts: "prey" },
|
|
60
|
+
prey: { role: "prey", kind: "goblin", idPrefix: "goblin", hunts: null },
|
|
54
61
|
food: { spawnedKind: "crumb", placedKind: "morsel" },
|
|
55
62
|
});
|
|
56
63
|
|
|
64
|
+
const CAST_ROLES = Object.freeze(["predator", "prey"]);
|
|
65
|
+
|
|
66
|
+
/** Which roles `role` hunts, as the cast declares it: a role name, a list of
|
|
67
|
+
* them, or null for a role that hunts nothing. A roles object that states no
|
|
68
|
+
* link at all keeps the pairing its two role names already name, so a cast
|
|
69
|
+
* written before `hunts` existed hunts exactly as it did. Pure. */
|
|
70
|
+
export function rolesHuntedBy(role, roles = MUDIII_ROLES) {
|
|
71
|
+
const entry = roles?.[role];
|
|
72
|
+
if (!entry) return [];
|
|
73
|
+
const declared = "hunts" in entry ? entry.hunts : (role === "predator" ? "prey" : null);
|
|
74
|
+
if (declared === null || declared === undefined) return [];
|
|
75
|
+
return [].concat(declared).filter((named) => CAST_ROLES.includes(named));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Which roles hunt `role`, read back off those same links. Empty for a role
|
|
79
|
+
* nothing preys on, which is what keeps a predator hunting when it catches
|
|
80
|
+
* sight of another predator rather than fleeing its own kind. Pure. */
|
|
81
|
+
export function rolesHunting(role, roles = MUDIII_ROLES) {
|
|
82
|
+
return CAST_ROLES.filter((other) => rolesHuntedBy(other, roles).includes(role));
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
const PLACEMENT_PREDICATE = "mgx:currently-in";
|
|
58
86
|
const MASS_PREDICATE = "mgx:hasMass";
|
|
59
87
|
const MOOD_PREDICATE = "mgx:feels";
|
|
@@ -366,15 +394,14 @@ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter, tieBreakScoreO
|
|
|
366
394
|
}
|
|
367
395
|
|
|
368
396
|
/** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
|
|
369
|
-
* terms from `awayFrom`.
|
|
370
|
-
* rung are this function.
|
|
397
|
+
* terms from `awayFrom`. The evade rung is this function.
|
|
371
398
|
*
|
|
372
399
|
* `opts.towardCell`, when given, breaks a tie among equally-safe cells in
|
|
373
400
|
* favor of whichever is closest to it — a fleeing prey that knows where food
|
|
374
401
|
* is should flee toward it, not toward whichever direction DIRECTION_DELTA's
|
|
375
402
|
* key order happens to check first. Opt-in and null by default, so a caller
|
|
376
|
-
* that never passes it
|
|
377
|
-
*
|
|
403
|
+
* that never passes it sees no change at all: same options, same scores, same
|
|
404
|
+
* first-wins tie order. */
|
|
378
405
|
export function greedyAway(fromCell, awayFrom, applyActions, { towardCell = null } = {}) {
|
|
379
406
|
if (!awayFrom) return fromCell;
|
|
380
407
|
return bestOneStepBy(
|
|
@@ -396,6 +423,29 @@ export function greedyToward(fromCell, towardCell, applyActions) {
|
|
|
396
423
|
);
|
|
397
424
|
}
|
|
398
425
|
|
|
426
|
+
/** One-ply greedy over BOTH distances at once, rather than one rung after the
|
|
427
|
+
* other: the reachable cell (or staying put) that maximizes
|
|
428
|
+
*
|
|
429
|
+
* weight * distance-from-`awayFrom` - (1 - weight) * distance-to-`towardCell`
|
|
430
|
+
*
|
|
431
|
+
* At weight 1 this is greedyAway and at weight 0 it is greedyToward. In
|
|
432
|
+
* between it lets an agent take a step toward food that costs it little or no
|
|
433
|
+
* distance from the thing hunting it, which strict priority rungs cannot do.
|
|
434
|
+
*
|
|
435
|
+
* Falls back to whichever single term it still has when the other cell is
|
|
436
|
+
* null, so a caller never has to check first. */
|
|
437
|
+
export function greedyBlend(fromCell, awayFrom, towardCell, applyActions, weight) {
|
|
438
|
+
if (!awayFrom) return greedyToward(fromCell, towardCell, applyActions);
|
|
439
|
+
if (!towardCell) return greedyAway(fromCell, awayFrom, applyActions);
|
|
440
|
+
const w = Number.isFinite(Number(weight)) ? Number(weight) : DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
|
|
441
|
+
return bestOneStepBy(
|
|
442
|
+
fromCell, applyActions,
|
|
443
|
+
(cell) => w * chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y)
|
|
444
|
+
- (1 - w) * chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y),
|
|
445
|
+
(score, bestScore) => score > bestScore,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
399
449
|
/** A seeded, uniform pick among staying put or any one-ply reachable cell.
|
|
400
450
|
* Deterministic and replayable, and it looks random to somebody watching.
|
|
401
451
|
* Both roles' last rung: a motionless predator reads as a broken page. */
|
|
@@ -483,14 +533,13 @@ const round2 = (n) => Math.round(n * 100) / 100;
|
|
|
483
533
|
// ---- the goal line and the mood word -------------------------------------------
|
|
484
534
|
// Every branch assigns a mood beside the goal sentence it renders, and that
|
|
485
535
|
// word is written as a real mgx:feels fact for the turn. The words are the four
|
|
486
|
-
// spider-fly already uses:
|
|
487
|
-
// is scared, anything wandering or foraging is calm, and anything
|
|
488
|
-
// is happy.
|
|
536
|
+
// spider-fly already uses: an agent mid-chase is angry, one fleeing something
|
|
537
|
+
// that hunts it is scared, anything wandering or foraging is calm, and anything
|
|
538
|
+
// that just ate is happy.
|
|
489
539
|
|
|
490
540
|
function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false, facing = null, held = false } = {}) {
|
|
491
541
|
switch (kind) {
|
|
492
542
|
case "driven": return held ? `driven by hand — holding at ${cell}, facing ${facing}.` : `driven by hand — stepping to ${cell}.`;
|
|
493
|
-
case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
|
|
494
543
|
case "chase":
|
|
495
544
|
if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
|
|
496
545
|
return catches ? `co-located with ${subject} — catching it.` : `standing over ${subject} — taking it.`;
|
|
@@ -874,22 +923,41 @@ export async function recastTownSquare(memoryDir, {
|
|
|
874
923
|
});
|
|
875
924
|
}
|
|
876
925
|
|
|
926
|
+
/** The opening cast, seeded: predators on open cells, prey on the perimeter
|
|
927
|
+
* they wander in from, and NO cell handed out twice.
|
|
928
|
+
*
|
|
929
|
+
* Every other placement path in this engine already refuses a cell something
|
|
930
|
+
* stands on — spawn-prey and spawn-food both filter the occupied set and
|
|
931
|
+
* simply don't spawn when nothing is left, and placeFood refuses a prop or
|
|
932
|
+
* another item. The page's own blocked-cell rule counts a single agent as
|
|
933
|
+
* enough to block a cell too, so a mint that stacked two would put the board
|
|
934
|
+
* in breach of the rule it opens under.
|
|
935
|
+
*
|
|
936
|
+
* Prey prefer the perimeter, but a perimeter with no room left puts one a
|
|
937
|
+
* cell inside rather than inside another animal. An agent goes unminted only
|
|
938
|
+
* when the whole board is full, which is the same answer spawn-prey gives. */
|
|
877
939
|
function seededRoster(layout, { predators, prey, roles, epoch }) {
|
|
878
940
|
const roster = {};
|
|
879
941
|
const taken = new Set();
|
|
880
942
|
const open = openCells(layout);
|
|
881
943
|
const edge = perimeterCells(layout);
|
|
944
|
+
const pickFreeCell = (preferred, id) => {
|
|
945
|
+
const free = preferred.filter((c) => !taken.has(c));
|
|
946
|
+
const options = free.length ? free : open.filter((c) => !taken.has(c));
|
|
947
|
+
if (!options.length) return null;
|
|
948
|
+
return seededPick(options, seedKey(layout.name, epoch, 0, id, "spawn"));
|
|
949
|
+
};
|
|
882
950
|
for (let i = 1; i <= predators; i += 1) {
|
|
883
951
|
const id = `${roles.predator.idPrefix}-${i}`;
|
|
884
|
-
const
|
|
885
|
-
|
|
952
|
+
const cell = pickFreeCell(open, id);
|
|
953
|
+
if (!cell) break;
|
|
886
954
|
taken.add(cell);
|
|
887
955
|
roster[id] = { role: "predator", cell, facing: DEFAULT_FACING };
|
|
888
956
|
}
|
|
889
957
|
for (let i = 1; i <= prey; i += 1) {
|
|
890
958
|
const id = `${roles.prey.idPrefix}-${i}`;
|
|
891
|
-
const
|
|
892
|
-
|
|
959
|
+
const cell = pickFreeCell(edge, id);
|
|
960
|
+
if (!cell) break;
|
|
893
961
|
taken.add(cell);
|
|
894
962
|
roster[id] = { role: "prey", cell, facing: DEFAULT_FACING };
|
|
895
963
|
}
|
|
@@ -1023,7 +1091,7 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
|
|
|
1023
1091
|
* `agents` and `items` and `ecology` are the frozen render payload — see
|
|
1024
1092
|
* townSquareTickPayload, which projects exactly those three plus the turn.
|
|
1025
1093
|
* `rungs` is the decision each live agent reached this turn ("driven" for a
|
|
1026
|
-
* hand-driven move, then "chase", "evade", "forage", "
|
|
1094
|
+
* hand-driven move, then "chase", "evade", "forage", "wander", and —
|
|
1027
1095
|
* on a cast that carries or spins webs —
|
|
1028
1096
|
* "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
|
|
1029
1097
|
* that decided and then died still has a rung and no longer has an `agents`
|
|
@@ -1053,6 +1121,10 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1053
1121
|
|
|
1054
1122
|
const carriesPrey = config.carryPreyToWeb === true;
|
|
1055
1123
|
const buildsWebs = config.buildWebs === true;
|
|
1124
|
+
const blendsPreyDecision = config.blendPreyDecision === true;
|
|
1125
|
+
const preyThreatWeight = Number.isFinite(Number(config.preyThreatWeight))
|
|
1126
|
+
? Number(config.preyThreatWeight)
|
|
1127
|
+
: DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
|
|
1056
1128
|
const boardNoun = lay.boardNoun ?? "square";
|
|
1057
1129
|
const webbedAt = (c) => hasActiveWebAt(lay, state, k, c.x, c.y, config.webDurationTurns);
|
|
1058
1130
|
// Widened in place as predators spin webs this tick, for the renderer's own
|
|
@@ -1070,6 +1142,10 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1070
1142
|
}
|
|
1071
1143
|
}
|
|
1072
1144
|
|
|
1145
|
+
const liveIdsOfRole = { predator: predators, prey };
|
|
1146
|
+
const agentsOfRoles = (roleList, exceptId) =>
|
|
1147
|
+
roleList.flatMap((r) => liveIdsOfRole[r] ?? []).filter((id) => id !== exceptId).sort();
|
|
1148
|
+
|
|
1073
1149
|
const decide = (agentId, role) => {
|
|
1074
1150
|
const fromCell = parseCellId(state.placements.get(agentId).cell);
|
|
1075
1151
|
// The visitor's hand, checked before any of the belief chain below. A
|
|
@@ -1090,8 +1166,14 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1090
1166
|
// visionRadius: Infinity call, not new belief machinery. The rival-threat
|
|
1091
1167
|
// lookup below stays on beliefOpts either way: this switch is about food.
|
|
1092
1168
|
const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
|
|
1093
|
-
|
|
1094
|
-
|
|
1169
|
+
// Who counts as a threat and who counts as quarry both come off the cast's
|
|
1170
|
+
// own hunts links. Nothing hunts a predator on either shipped board, so a
|
|
1171
|
+
// predator's threat list is empty and a second predator in view is just
|
|
1172
|
+
// another animal on the square.
|
|
1173
|
+
const huntedRoles = rolesHuntedBy(role, roles);
|
|
1174
|
+
const huntsAgents = huntedRoles.length > 0;
|
|
1175
|
+
const hunters = agentsOfRoles(rolesHunting(role, roles), agentId);
|
|
1176
|
+
const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, hunters, state, beliefOpts);
|
|
1095
1177
|
|
|
1096
1178
|
let rung;
|
|
1097
1179
|
let nextCell;
|
|
@@ -1100,8 +1182,7 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1100
1182
|
let mood;
|
|
1101
1183
|
let drivenFacing = null;
|
|
1102
1184
|
// A carried prey and its captor both leave the ordinary chain. A carrying
|
|
1103
|
-
// predator never drops its catch to
|
|
1104
|
-
// nothing here eats a predator, so "avoid" is contention, not survival.
|
|
1185
|
+
// predator never drops its catch to chase a second one.
|
|
1105
1186
|
const carriedPreyId = (carriesPrey && role === "predator") ? (state.carrying.get(agentId)?.prey ?? null) : null;
|
|
1106
1187
|
const isCarrying = Boolean(carriedPreyId) && prey.includes(carriedPreyId);
|
|
1107
1188
|
const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
|
|
@@ -1152,24 +1233,47 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1152
1233
|
goal = goalLine("trapped");
|
|
1153
1234
|
mood = "scared";
|
|
1154
1235
|
} else if (threat) {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1236
|
+
const towardFood = huntsAgents
|
|
1237
|
+
? null
|
|
1238
|
+
: nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
|
|
1239
|
+
if (blendsPreyDecision && !huntsAgents && towardFood) {
|
|
1240
|
+
// The one case the two rungs disagree about: this prey believes a
|
|
1241
|
+
// predator AND food, so a strict order has to pick between them and a
|
|
1242
|
+
// score does not.
|
|
1243
|
+
nextCell = greedyBlend(fromCell, threat.cell, towardFood.cell, applyActions, preyThreatWeight);
|
|
1244
|
+
const closedOnFood = chebyshevDistance(fromCell.x, fromCell.y, towardFood.cell.x, towardFood.cell.y)
|
|
1245
|
+
> chebyshevDistance(nextCell.x, nextCell.y, towardFood.cell.x, towardFood.cell.y);
|
|
1246
|
+
// The score is one number, but the step it buys is still one of two
|
|
1247
|
+
// legible things: this move closed on the crumb, or it did not. The
|
|
1248
|
+
// rung and the goal line say which, so no surface has to render
|
|
1249
|
+
// "mostly evading, somewhat hungry".
|
|
1250
|
+
rung = closedOnFood ? "forage" : "evade";
|
|
1251
|
+
plan = stepPlan(fromCell, nextCell);
|
|
1252
|
+
goal = closedOnFood
|
|
1253
|
+
? goalLine("forage", {
|
|
1254
|
+
subject: towardFood.subject,
|
|
1255
|
+
cell: cellId(towardFood.cell.x, towardFood.cell.y),
|
|
1256
|
+
arrived: nextCell.x === towardFood.cell.x && nextCell.y === towardFood.cell.y,
|
|
1257
|
+
})
|
|
1258
|
+
: goalLine("evade", { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
|
|
1259
|
+
mood = closedOnFood ? "calm" : "scared";
|
|
1260
|
+
} else {
|
|
1261
|
+
rung = "evade";
|
|
1262
|
+
// An agent that already knows where food is should flee toward it, not
|
|
1263
|
+
// away from it — among cells that are equally safe, break the tie
|
|
1264
|
+
// toward the nearest believed crumb. An agent that hunts has no food
|
|
1265
|
+
// cell to offer, so its ties resolve on first-wins order alone.
|
|
1266
|
+
nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
|
|
1267
|
+
plan = stepPlan(fromCell, nextCell);
|
|
1268
|
+
goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
|
|
1269
|
+
mood = "scared";
|
|
1270
|
+
}
|
|
1167
1271
|
} else {
|
|
1168
|
-
const quarry =
|
|
1169
|
-
? nearestBelievedTarget(agentId, fromCell,
|
|
1272
|
+
const quarry = huntsAgents
|
|
1273
|
+
? nearestBelievedTarget(agentId, fromCell, agentsOfRoles(huntedRoles, agentId), state, beliefOpts)
|
|
1170
1274
|
: nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
|
|
1171
1275
|
if (quarry) {
|
|
1172
|
-
rung =
|
|
1276
|
+
rung = huntsAgents ? "chase" : "forage";
|
|
1173
1277
|
const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
|
|
1174
1278
|
if (path && path.actions.length) {
|
|
1175
1279
|
nextCell = path.states[1];
|
|
@@ -1183,7 +1287,7 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
1183
1287
|
}
|
|
1184
1288
|
const arrived = nextCell.x === quarry.cell.x && nextCell.y === quarry.cell.y;
|
|
1185
1289
|
goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived, catches: carriesPrey });
|
|
1186
|
-
mood =
|
|
1290
|
+
mood = huntsAgents ? "angry" : "calm";
|
|
1187
1291
|
} else if (role === "predator" && buildsWebs) {
|
|
1188
1292
|
// A web-spinning predator with nothing in sight has something better to
|
|
1189
1293
|
// do than wander: hold this cell, and spin a web here unless a live one
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
// renderResearchHtml() is pure: no I/O, deterministic output for identical
|
|
20
20
|
// input. scripts/build-demo-site.mjs calls it directly and writes the result to
|
|
21
21
|
// public/research.html, after research-browser.bundle.js already exists.
|
|
22
|
-
import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
|
|
23
|
-
import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
|
|
22
|
+
import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
|
|
23
|
+
import { fetchWithProgress, loadSeedPayload, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
|
|
24
24
|
import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
|
|
25
25
|
import { loadWinkVendor } from "./viz-boot.mjs";
|
|
26
26
|
import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
|
|
@@ -108,7 +108,8 @@ ${DASH_DARK_CHROME_CSS}
|
|
|
108
108
|
header.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.4rem; flex-wrap: wrap; padding: .6rem 0 1rem; border-bottom: 1px solid var(--line); margin-bottom: 1.3rem; }
|
|
109
109
|
.brand { display: flex; flex-direction: column; gap: .3rem; max-width: 640px; }
|
|
110
110
|
.eyebrow { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
|
111
|
-
|
|
111
|
+
${EYEBROW_LINKS_CSS}
|
|
112
|
+
.subtitle { margin: 0; font-size: .92rem; font-weight: 400; color: var(--ink); opacity: .82; max-width: 58ch; }
|
|
112
113
|
.statuspanel { display: flex; flex-wrap: wrap; gap: .5rem 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
|
|
113
114
|
.statuspanel .stat { display: flex; flex-direction: column; gap: .14rem; min-width: 7rem; }
|
|
114
115
|
.statuspanel .stat-label { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
|
@@ -240,8 +241,8 @@ ${DASH_DARK_CHROME_CSS}
|
|
|
240
241
|
<div class="wrap">
|
|
241
242
|
<header class="topbar">
|
|
242
243
|
<div class="brand">
|
|
243
|
-
<span class="eyebrow"
|
|
244
|
-
<
|
|
244
|
+
<span class="eyebrow">${demoEyebrowHtml("research", "research")}</span>
|
|
245
|
+
<h1 class="subtitle">Grow one graph three ways. Watch what it learns, then ask a question scoped to the sources you trust.</h1>
|
|
245
246
|
</div>
|
|
246
247
|
<div class="statuspanel" id="statusPanel" aria-live="polite">
|
|
247
248
|
<div class="stat stat-facts"><span class="stat-label">facts in the graph</span><span class="stat-value" id="statFacts">—</span></div>
|
|
@@ -353,6 +354,7 @@ ${DASH_DARK_CHROME_CSS}
|
|
|
353
354
|
const sourceLabelFor = ${sourceLabelFor.toString()};
|
|
354
355
|
const factTripleParts = ${factTripleParts.toString()};
|
|
355
356
|
const fetchWithProgress = ${fetchWithProgress.toString()};
|
|
357
|
+
const loadSeedPayload = ${loadSeedPayload.toString()};
|
|
356
358
|
const createTicker = ${createTicker.toString()};
|
|
357
359
|
const prefersReducedMotion = ${prefersReducedMotion.toString()};
|
|
358
360
|
const loadWinkVendor = ${loadWinkVendor.toString()};
|
|
@@ -396,25 +398,17 @@ ${DASH_DARK_CHROME_CSS}
|
|
|
396
398
|
|
|
397
399
|
let seedPayload = null;
|
|
398
400
|
let seedFacts = 0;
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
// signal of that. The bust param forces a fresh fetch past that one entry.
|
|
401
|
+
// The same starter memory chat.html and ingest.html load, reported the same
|
|
402
|
+
// way: tmct.seed carries whether it arrived, so a page with an empty store
|
|
403
|
+
// can say which kind of empty it is.
|
|
404
|
+
window.tmct.seed = { state: "loading", facts: 0 };
|
|
404
405
|
async function fetchSeed() {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return;
|
|
412
|
-
} catch (err) {
|
|
413
|
-
if (attempt === 2) {
|
|
414
|
-
seedPayload = null;
|
|
415
|
-
console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
406
|
+
const outcome = await loadSeedPayload(fetchWithProgress, "./chat-seed.json", SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
|
|
407
|
+
seedPayload = outcome.payload;
|
|
408
|
+
seedFacts = outcome.status.facts;
|
|
409
|
+
window.tmct.seed = outcome.status;
|
|
410
|
+
if (outcome.status.state === "failed") {
|
|
411
|
+
console.error("tmct research: chat-seed.json unavailable — starting unseeded (" + outcome.status.error + ")");
|
|
418
412
|
}
|
|
419
413
|
}
|
|
420
414
|
async function newSession() {
|
|
@@ -130,6 +130,12 @@ const SPIDER_FLY_SEE_RE = /^what (?:does|can) the (spider|fly)(?:-(\d+))?\s+see[
|
|
|
130
130
|
const WORLD_OPENING_FALLBACK =
|
|
131
131
|
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
|
|
132
132
|
|
|
133
|
+
/** The word that steps the board, said on the OPENING turn. It used to appear
|
|
134
|
+
* only on re-entry, so a first-time player was told to watch a board that
|
|
135
|
+
* never moved. Appended to whatever opening the worlds pack carries, so the
|
|
136
|
+
* pack owns the scene and this owns the control. */
|
|
137
|
+
const ADVANCE_HINT = 'Say "tick" to advance a turn.';
|
|
138
|
+
|
|
133
139
|
// ---- the opening turn: load the shipped board through the worlds pack -------
|
|
134
140
|
|
|
135
141
|
async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
|
|
@@ -164,7 +170,7 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig
|
|
|
164
170
|
const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1, config: gameConfig?.spiderFly });
|
|
165
171
|
planHolder.state = { spiderFly: { turn: 0 } };
|
|
166
172
|
const opener = started
|
|
167
|
-
?
|
|
173
|
+
? `${payload.meta?.opening || WORLD_OPENING_FALLBACK} ${ADVANCE_HINT}`
|
|
168
174
|
: 'back to the spider-and-fly board — the spider and fly are already in play. Say "tick" to advance, or address one, e.g. "@spider the fly is east".';
|
|
169
175
|
return {
|
|
170
176
|
text: opener,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// bundle's own real ES exports instead, since (unlike ledger-viz, which
|
|
33
33
|
// reuses a FIXED shared bundle it can't extend for one page's own needs) this
|
|
34
34
|
// page ships its own dedicated bundle and can just export what it needs.
|
|
35
|
-
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml } from "./viz-theme.mjs";
|
|
35
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
|
|
36
36
|
import { createTicker } from "./viz-ticker.mjs";
|
|
37
37
|
import { loadWinkVendor } from "./viz-boot.mjs";
|
|
38
38
|
import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId, agentKindOf } from "../domain/spider-fly-world.mjs";
|
|
@@ -250,6 +250,7 @@ ${THEME_TOKENS_CSS}
|
|
|
250
250
|
.mono { font-family: ${MONO_STACK}; }
|
|
251
251
|
main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
|
|
252
252
|
.eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-accent); }
|
|
253
|
+
${EYEBROW_LINKS_CSS}
|
|
253
254
|
h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
|
|
254
255
|
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
255
256
|
button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
@@ -443,7 +444,7 @@ ${THEME_TOKENS_CSS}
|
|
|
443
444
|
<main>
|
|
444
445
|
<div class="stage page-head">
|
|
445
446
|
<div class="head-inner">
|
|
446
|
-
<div class="eyebrow"
|
|
447
|
+
<div class="eyebrow">${demoEyebrowHtml("spider-fly", "spider and fly")}</div>
|
|
447
448
|
<h1>Multiple competing planning agents</h1>
|
|
448
449
|
</div>
|
|
449
450
|
<div></div>
|
|
@@ -1007,12 +1008,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
|
|
|
1007
1008
|
chatqEl.focus();
|
|
1008
1009
|
});
|
|
1009
1010
|
}
|
|
1011
|
+
// A direction pill REPLACES the claim rather than appending to it — unlike
|
|
1012
|
+
// adventure.html/mud.html's claim pills, which are meant to compose ("look
|
|
1013
|
+
// at" then "the book"), these four are mutually exclusive readings of the
|
|
1014
|
+
// SAME fact (where one agent is), so a second click was building
|
|
1015
|
+
// "@spider the fly is north the fly is east", which the grammar has never
|
|
1016
|
+
// accepted and never will: a thing has one position, not several at once.
|
|
1017
|
+
// The address prefix survives a direction click (so "@fly" then a
|
|
1018
|
+
// direction still addresses the fly), the same way it already did before
|
|
1019
|
+
// this fix — only the claim after it is replaced rather than grown.
|
|
1010
1020
|
for (const btn of directionPillEls) {
|
|
1011
1021
|
btn.addEventListener("click", () => {
|
|
1012
1022
|
const kind = addresseeKindOf(chatqEl.value) || "spider";
|
|
1013
|
-
|
|
1014
|
-
if (!addresseeKindOf(value)) value = "@" + kind + " " + value.trimStart();
|
|
1015
|
-
chatqEl.value = value.replace(/\\s+$/, "") + " " + btn.textContent;
|
|
1023
|
+
chatqEl.value = "@" + kind + " " + btn.textContent;
|
|
1016
1024
|
refreshPills();
|
|
1017
1025
|
chatqEl.focus();
|
|
1018
1026
|
});
|
|
@@ -51,7 +51,7 @@ import { spriteFactRows } from "../domain/sprite-facts.mjs";
|
|
|
51
51
|
import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
|
|
52
52
|
import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
|
|
53
53
|
import { join } from "node:path";
|
|
54
|
-
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
|
|
54
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
|
|
55
55
|
|
|
56
56
|
const DEFAULT_TITLE = "tmct — the sprite library";
|
|
57
57
|
const MAX_CHAIN_DISPLAY = 6;
|
|
@@ -905,6 +905,7 @@ ${THEME_TOKENS_CSS}
|
|
|
905
905
|
.appbar { display: flex; align-items: flex-end; gap: 1rem; margin: 0 -1.2rem; padding: .6rem 1.2rem 0; background: var(--ai-bar); }
|
|
906
906
|
.appbar h1 { font-family: ${MONO_STACK}; font-size: .84rem; font-weight: 600; letter-spacing: .02em; margin: 0; padding: .32rem .8rem .38rem; background: var(--ai-panel); color: var(--ink); border-radius: 4px 4px 0 0; }
|
|
907
907
|
.appbar .doc-sub { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: color-mix(in srgb, var(--ai-bar-ink) 65%, transparent); padding-bottom: .5rem; }
|
|
908
|
+
${EYEBROW_LINKS_CSS}
|
|
908
909
|
.topbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; background: var(--ai-panel); border-bottom: 1px solid var(--ai-edge); margin: 0 -1.2rem 1.4rem; padding: .5rem 1.2rem; }
|
|
909
910
|
.jump { font-family: ${MONO_STACK}; font-size: .7rem; padding: .2rem .6rem; border: 1px solid var(--ai-edge); border-radius: 3px; background: transparent; color: var(--ink); text-decoration: none; }
|
|
910
911
|
.jump:hover { border-color: var(--corpus); color: var(--corpus); }
|
|
@@ -982,7 +983,7 @@ ${dockCss}</style>
|
|
|
982
983
|
<main>
|
|
983
984
|
<header class="appbar">
|
|
984
985
|
<h1>Sprites</h1>
|
|
985
|
-
<span class="doc-sub"
|
|
986
|
+
<span class="doc-sub">${demoEyebrowHtml("sprites", "the sprite library")}</span>
|
|
986
987
|
</header>
|
|
987
988
|
${composerHtml}
|
|
988
989
|
${dockHtml}
|
|
@@ -32,6 +32,26 @@ export function embedScriptText(js) {
|
|
|
32
32
|
return String(js ?? "").replaceAll("</script", "<\\/script");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The three-link nav every demo page's header opens with: the site's own
|
|
36
|
+
* name (home), the demo's own name (its own page) and "about" (its about
|
|
37
|
+
* page). `page` is the demo's key in scripts/site-pages.mjs's DEMO_PAGES,
|
|
38
|
+
* which is also its filename stem (e.g. "mud" -> mud.html); the about-page
|
|
39
|
+
* suffix below mirrors that same file's `aboutPageOf`, restated rather than
|
|
40
|
+
* imported since scripts/ sits outside src/ and never ships with the
|
|
41
|
+
* published package. Returns the inner markup only — the caller supplies
|
|
42
|
+
* the wrapping element (div/h1/span) and keeps whatever class and
|
|
43
|
+
* neighbouring markup its own header already carries. Style hook:
|
|
44
|
+
* EYEBROW_LINKS_CSS. */
|
|
45
|
+
export function demoEyebrowHtml(page, label) {
|
|
46
|
+
return `<span class="eyebrow-links"><a href="./index.html">tmct</a> · <a href="./${page}.html">${escapeHtml(label)}</a> · <a href="./${page}-about.html">about</a></span>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Styling every page that calls demoEyebrowHtml splices into its own inline
|
|
50
|
+
* <style>: the links read in the eyebrow's own colour (each page sets that
|
|
51
|
+
* colour itself), underlining only on hover/focus. */
|
|
52
|
+
export const EYEBROW_LINKS_CSS = `.eyebrow-links a { color: inherit; text-decoration: none; }
|
|
53
|
+
.eyebrow-links a:hover, .eyebrow-links a:focus-visible { text-decoration: underline; }`;
|
|
54
|
+
|
|
35
55
|
/** A world name read as a name in a scenario dropdown: "mud-garden" ->
|
|
36
56
|
* "mud garden". The world's own hyphenated id is the only thing every caller
|
|
37
57
|
* is guaranteed to have, so a scenario that wants a hand-written label passes
|
|
@@ -55,6 +55,10 @@ export function createTicker({
|
|
|
55
55
|
} = {}) {
|
|
56
56
|
const state = { playing: false, animating: false };
|
|
57
57
|
const render = () => onRender({ ...state });
|
|
58
|
+
// The promise of whatever onTick() call is currently in flight, or null
|
|
59
|
+
// between ticks — reset() awaits this (see below) rather than bailing out
|
|
60
|
+
// when a click lands mid-tick, so a reset can never be silently dropped.
|
|
61
|
+
let inFlight = null;
|
|
58
62
|
|
|
59
63
|
/** Advance exactly one step, honoring `hasNext`/`animating` guards. Returns
|
|
60
64
|
* whether it actually advanced. */
|
|
@@ -62,7 +66,9 @@ export function createTicker({
|
|
|
62
66
|
if (state.animating || !hasNext()) return false;
|
|
63
67
|
state.animating = true;
|
|
64
68
|
render();
|
|
65
|
-
|
|
69
|
+
inFlight = Promise.resolve(onTick());
|
|
70
|
+
await inFlight;
|
|
71
|
+
inFlight = null;
|
|
66
72
|
state.animating = false;
|
|
67
73
|
render();
|
|
68
74
|
return true;
|
|
@@ -95,9 +101,16 @@ export function createTicker({
|
|
|
95
101
|
render();
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
/** Stop and rewind. `state.playing` drops immediately, even mid-tick, so
|
|
105
|
+
* `play()`'s own loop can never start one more tick once this has been
|
|
106
|
+
* called — a reset clicked while a tick is animating used to return here
|
|
107
|
+
* before touching `playing` at all, which left the loop running right
|
|
108
|
+
* past it. If a tick IS in flight, this waits for it to actually settle
|
|
109
|
+
* (never calling `onReset` while `onTick` might still be touching the
|
|
110
|
+
* same state/DOM) before rewinding, rather than dropping the reset. */
|
|
98
111
|
async function reset() {
|
|
99
|
-
if (state.animating) return;
|
|
100
112
|
state.playing = false;
|
|
113
|
+
if (inFlight) await inFlight;
|
|
101
114
|
if (onReset) await onReset();
|
|
102
115
|
render();
|
|
103
116
|
}
|