@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4
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/package.json +2 -1
- package/src/domain/game-config.mjs +10 -4
- package/src/domain/hanoi-lesson.mjs +53 -0
- package/src/domain/spider-fly-world.mjs +16 -0
- package/src/services/adventure-editor.mjs +361 -0
- package/src/services/adventure-viz.mjs +422 -51
- package/src/services/ledger-viz.mjs +239 -3
- package/src/services/plan-pddl.mjs +245 -0
- package/src/services/plan-viz.mjs +324 -67
- package/src/services/spider-fly-turn.mjs +120 -3
- package/src/services/spider-fly-viz.mjs +341 -22
- package/src/services/spider-fly.mjs +337 -143
- package/src/surfaces/web/adventure-browser-entry.mjs +34 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +10 -4
- package/src/surfaces/web/plan-browser-entry.mjs +114 -0
- package/src/surfaces/web/spider-fly-browser-entry.mjs +33 -1
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
// plan-viz.mjs — renders a computed plan (result.plan from the chat plan lane)
|
|
2
2
|
// as a self-contained, animated HTML page: the "blocks" archetype.
|
|
3
3
|
//
|
|
4
|
-
// A pure layout step (computeBlocksLayout)
|
|
5
|
-
// (renderPlanHtml). No I/O here —
|
|
6
|
-
// map (rendersAs), and the
|
|
7
|
-
// wiring time.
|
|
8
|
-
|
|
4
|
+
// A pure layout step (computeBlocksLayout), a pure page-data reducer
|
|
5
|
+
// (planToPageData) and a pure string builder (renderPlanHtml). No I/O here —
|
|
6
|
+
// callers pass the plan, the class→archetype map (rendersAs), and the
|
|
7
|
+
// size-order pairs; both derive from fact rows at wiring time.
|
|
8
|
+
//
|
|
9
|
+
// The page ships as a HYBRID, on purpose: the initial board/movelist/PDDL
|
|
10
|
+
// panel are baked in at render time exactly as before (so a plain
|
|
11
|
+
// `renderPlanHtml({ plan, ... })` call, with no live bundle anywhere nearby,
|
|
12
|
+
// still produces a fully working replay — the CLI's own `--render blocks`
|
|
13
|
+
// output, and every existing test, keep working unchanged), while an
|
|
14
|
+
// optional sibling `./plan-browser.bundle.js` (built by
|
|
15
|
+
// scripts/build-plan-bundle.mjs, wired in by scripts/build-demo-site.mjs)
|
|
16
|
+
// layers live re-solving on top: disk-count/max-depth controls and a
|
|
17
|
+
// chat-assert dock that can mount a FRESH plan without a page reload. The
|
|
18
|
+
// inline script degrades honestly when that sibling script is absent or
|
|
19
|
+
// fails to load — the live controls disable themselves rather than pretend
|
|
20
|
+
// to work.
|
|
9
21
|
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
|
|
22
|
+
import { planToPddl } from "./plan-pddl.mjs";
|
|
10
23
|
|
|
11
24
|
const BOARD_W = 640;
|
|
12
25
|
const BOARD_H = 260;
|
|
@@ -220,24 +233,25 @@ function phasesFor(actions, ranks, sized) {
|
|
|
220
233
|
|
|
221
234
|
const displayPredicate = (p) => String(p).replace(/^mgx:/, "").replace(/-/g, " ");
|
|
222
235
|
|
|
223
|
-
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
/** The board/movelist page data a rendered plan page embeds — one board
|
|
237
|
+
* layout snapshot per plan.states entry, plus the labels/goal-lines/facts
|
|
238
|
+
* text the movelist and facts strip read. Pure; the same shape whether
|
|
239
|
+
* computed at render time (the initial embed) or live in the browser after
|
|
240
|
+
* a re-solve (plan-browser-entry.mjs re-exports this for that reason). */
|
|
241
|
+
export function planToPageData({ plan, rendersAs = {}, sizeOrder = [] } = {}) {
|
|
228
242
|
const actions = plan?.actions || [];
|
|
229
243
|
const layout = computeBlocksLayout({ plan, rendersAs, sizeOrder });
|
|
230
244
|
const stepGoals =
|
|
231
245
|
Array.isArray(plan?.stepGoals) && plan.stepGoals.length === actions.length
|
|
232
246
|
? plan.stepGoals
|
|
233
247
|
: null;
|
|
234
|
-
const labels = actions.map((a
|
|
248
|
+
const labels = actions.map((a) => a.label || `move ${a.subject} onto ${a.target}`);
|
|
235
249
|
const factsPerStep = (plan?.states || []).map((rows) =>
|
|
236
250
|
[...rows]
|
|
237
251
|
.map((r) => `${r.subject} ${displayPredicate(r.predicate)} ${r.object}`)
|
|
238
252
|
.sort(),
|
|
239
253
|
);
|
|
240
|
-
|
|
254
|
+
return {
|
|
241
255
|
actions,
|
|
242
256
|
labels,
|
|
243
257
|
stepGoals,
|
|
@@ -247,7 +261,30 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
|
|
|
247
261
|
board: layout.board,
|
|
248
262
|
facts: factsPerStep,
|
|
249
263
|
phases: phasesFor(actions, layout.ranks, layout.sized),
|
|
250
|
-
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `{ rendersAs, sizeOrder }` derived straight from a solved plan's own
|
|
268
|
+
* `domain.renderHints`/`domain.ordering` — the same derivation bin/tmct.mjs's
|
|
269
|
+
* `--render blocks` step does, factored out so the live re-solve path
|
|
270
|
+
* (plan-browser-entry.mjs) never has to duplicate it. */
|
|
271
|
+
export function renderInputsFromPlan(plan) {
|
|
272
|
+
const rendersAs = plan?.domain?.renderHints ?? {};
|
|
273
|
+
const sizeOrder = (plan?.domain?.ordering ?? [])
|
|
274
|
+
.filter((row) => /-than$/.test(String(row.predicate || "")))
|
|
275
|
+
.map((row) => [row.subject, row.object]);
|
|
276
|
+
return { rendersAs, sizeOrder };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The self-contained plan page. Consumes the plan-lane contract
|
|
281
|
+
* ({actions, states, stepGoals, goal, domain}) plus the render inputs.
|
|
282
|
+
*/
|
|
283
|
+
export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } = {}) {
|
|
284
|
+
const actions = plan?.actions || [];
|
|
285
|
+
const pageData = planToPageData({ plan, rendersAs, sizeOrder });
|
|
286
|
+
const embedded = embedJson(pageData);
|
|
287
|
+
const pddlText = planToPddl(plan);
|
|
251
288
|
const pageTitle = title || `tmct plan — ${actions.length} move${actions.length === 1 ? "" : "s"}`;
|
|
252
289
|
|
|
253
290
|
return `<!doctype html>
|
|
@@ -256,6 +293,25 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
|
|
|
256
293
|
<meta charset="utf-8">
|
|
257
294
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
258
295
|
<title>${escapeHtml(pageTitle)}</title>
|
|
296
|
+
<!--
|
|
297
|
+
Import map: resolves the "wink-nlp"/"wink-eng-lite-web-model" bare specifiers the
|
|
298
|
+
live re-solve session's own dynamic import() needs (pinned to the exact versions
|
|
299
|
+
package.json depends on) to esm.sh CDN builds — the same seam index.html's own
|
|
300
|
+
import map wires up for the embedded chat, mirrored here because this page can be
|
|
301
|
+
opened standalone (no import map inherited from a host document). The bundle
|
|
302
|
+
itself (./plan-browser.bundle.js) never touches wink-nlp directly — wink-model.mjs's
|
|
303
|
+
own header explains why a static import would drag the ~1 MB model into every
|
|
304
|
+
bundle; only the page's own inline script performs this CDN import, exactly like
|
|
305
|
+
public/chat-ui.mjs's own tryLoadWink().
|
|
306
|
+
-->
|
|
307
|
+
<script type="importmap">
|
|
308
|
+
{
|
|
309
|
+
"imports": {
|
|
310
|
+
"wink-nlp": "https://esm.sh/wink-nlp@2.4.0",
|
|
311
|
+
"wink-eng-lite-web-model": "https://esm.sh/wink-eng-lite-web-model@1.8.1"
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
</script>
|
|
259
315
|
<style>
|
|
260
316
|
${THEME_TOKENS_CSS}
|
|
261
317
|
html { background: var(--bg); }
|
|
@@ -294,13 +350,46 @@ h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
|
|
|
294
350
|
.movelist .phasehead:hover { color: var(--taught); background: var(--taught-soft); }
|
|
295
351
|
.movelist .phasehead:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
296
352
|
@media (prefers-reduced-motion: reduce) { .block { transition: none !important; } }
|
|
353
|
+
.liveControls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1rem; padding: .55rem .7rem; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-family: ${MONO_STACK}; font-size: .74rem; }
|
|
354
|
+
.liveControls label { display: flex; align-items: center; gap: .35rem; color: var(--muted); }
|
|
355
|
+
.liveControls input[type="number"] { width: 3.6rem; font-family: ${MONO_STACK}; font-size: .74rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 5px; padding: .2rem .4rem; }
|
|
356
|
+
.liveControls button { font-family: ${MONO_STACK}; font-size: .74rem; padding: .3rem .7rem; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
|
|
357
|
+
.liveControls button:hover:not(:disabled) { border-color: var(--taught); }
|
|
358
|
+
.liveControls button:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
359
|
+
.liveControls button:disabled { opacity: .4; cursor: default; }
|
|
360
|
+
.liveControls .livestatus { color: var(--muted); margin-left: auto; }
|
|
361
|
+
.lower { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; margin-top: 1.2rem; }
|
|
362
|
+
@media (max-width: 780px) { .lower { grid-template-columns: 1fr; } }
|
|
363
|
+
.chat, .pddlpanel { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .75rem; min-width: 0; }
|
|
364
|
+
.chat h2, .pddlpanel h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
|
|
365
|
+
.chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 180px; overflow-y: auto; margin-bottom: .5rem; }
|
|
366
|
+
.chatlog:empty { display: none; margin-bottom: 0; }
|
|
367
|
+
.chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
|
|
368
|
+
.chatlog .u::before { content: "tmct> "; color: var(--taught); }
|
|
369
|
+
.chatlog .a { font-size: .85rem; line-height: 1.4; white-space: pre-wrap; }
|
|
370
|
+
.chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
|
|
371
|
+
.chatlog:empty + .chatask { border-top: none; padding-top: 0; }
|
|
372
|
+
.chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
|
|
373
|
+
.chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
|
|
374
|
+
.chatask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
375
|
+
.chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
|
|
376
|
+
.pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); color: var(--ink); cursor: pointer; white-space: nowrap; }
|
|
377
|
+
.pill:hover { border-color: var(--taught); }
|
|
378
|
+
.pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
|
|
379
|
+
.pddlpanel pre { margin: 0; font-family: ${MONO_STACK}; font-size: .68rem; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 420px; overflow-y: auto; color: var(--ink); }
|
|
297
380
|
</style>
|
|
298
381
|
</head>
|
|
299
382
|
<body>
|
|
300
383
|
<main>
|
|
301
384
|
<div class="head">
|
|
302
|
-
<h1>${escapeHtml(pageTitle)}</h1>
|
|
303
|
-
<span class="chip">blocks archetype · ${
|
|
385
|
+
<h1 id="pageTitle">${escapeHtml(pageTitle)}</h1>
|
|
386
|
+
<span class="chip">blocks archetype · ${pageData.layouts.length} snapshots · plan: findActionPath</span>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="liveControls" id="liveControls">
|
|
389
|
+
<label for="diskCount">disks <input id="diskCount" type="number" min="1" max="7" value="3"></label>
|
|
390
|
+
<label for="maxDepth">max search depth <input id="maxDepth" type="number" min="1" max="999" value="300"></label>
|
|
391
|
+
<button id="resolveBtn" type="button">solve a fresh puzzle</button>
|
|
392
|
+
<span class="livestatus" id="liveStatus"></span>
|
|
304
393
|
</div>
|
|
305
394
|
<div class="stage">
|
|
306
395
|
<div>
|
|
@@ -317,51 +406,57 @@ h1 { font-size: 1.15rem; margin: 0 0 .8rem; }
|
|
|
317
406
|
</div>
|
|
318
407
|
<ol class="movelist" id="movelist"></ol>
|
|
319
408
|
</div>
|
|
409
|
+
<div class="lower">
|
|
410
|
+
<div class="chat">
|
|
411
|
+
<h2>teach it something, then ask it to solve again</h2>
|
|
412
|
+
<div class="chatlog" id="chatlog" aria-live="polite"></div>
|
|
413
|
+
<form class="chatask" id="chatform">
|
|
414
|
+
<span class="prompt">tmct></span>
|
|
415
|
+
<input id="chatq" type="text" placeholder="disk-1 is smaller than disk-4." aria-label="Teach a fact, or ask it to solve">
|
|
416
|
+
</form>
|
|
417
|
+
<div class="chatpills" id="chatpills" role="group" aria-label="quick phrases to fill the chat input">
|
|
418
|
+
<button type="button" class="pill" data-fill="solve it.">solve it</button>
|
|
419
|
+
<button type="button" class="pill" data-fill="what moves are legal now?">what moves are legal now?</button>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="pddlpanel">
|
|
423
|
+
<h2>PDDL + OWL/RDF plan artifact</h2>
|
|
424
|
+
<pre id="pddlOut">${escapeHtml(pddlText)}</pre>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
320
427
|
</main>
|
|
321
428
|
<script>
|
|
322
429
|
const PLAN = ${embedded};
|
|
430
|
+
</script>
|
|
431
|
+
<script src="./plan-browser.bundle.js"></script>
|
|
432
|
+
<script>
|
|
323
433
|
(function () {
|
|
324
434
|
"use strict";
|
|
325
|
-
const
|
|
435
|
+
const pageTitleEl = document.getElementById("pageTitle");
|
|
326
436
|
const board = document.getElementById("board");
|
|
327
437
|
const stepLabel = document.getElementById("stepLabel");
|
|
328
438
|
const goalline = document.getElementById("goalline");
|
|
329
439
|
const factsEl = document.getElementById("facts");
|
|
330
440
|
const movelist = document.getElementById("movelist");
|
|
441
|
+
const pddlEl = document.getElementById("pddlOut");
|
|
331
442
|
const btn = {
|
|
332
443
|
reset: document.getElementById("reset"), back: document.getElementById("back"),
|
|
333
444
|
play: document.getElementById("play"), next: document.getElementById("next"),
|
|
334
445
|
};
|
|
335
446
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
336
|
-
let step = 0, playing = false, animating = false;
|
|
337
447
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
const lab = document.createElement("div");
|
|
348
|
-
lab.className = a.kind === "slot" ? "anchorlabel" : "circlelabel";
|
|
349
|
-
lab.textContent = a.id; lab.style.left = a.x + "px";
|
|
350
|
-
if (a.kind !== "slot") lab.style.top = (a.y + 4) + "px";
|
|
351
|
-
board.appendChild(lab);
|
|
352
|
-
}
|
|
353
|
-
const blockEls = {};
|
|
354
|
-
for (const item of PLAN.layouts[0].items) {
|
|
355
|
-
if (item.kind !== "block") continue;
|
|
356
|
-
const el = document.createElement("div");
|
|
357
|
-
el.className = "block"; el.textContent = item.id;
|
|
358
|
-
el.style.width = item.w + "px"; el.style.background = item.fill;
|
|
359
|
-
board.appendChild(el); blockEls[item.id] = el;
|
|
360
|
-
}
|
|
448
|
+
// ---- the board/movelist renderer: a live re-solve calls mountPlan(data)
|
|
449
|
+
// again with a FRESH page-data object (planToPageData's own shape), so
|
|
450
|
+
// every closure below reads the mutable "plan" binding, never the
|
|
451
|
+
// top-level immutable PLAN constant (which stays exactly the initial
|
|
452
|
+
// server-rendered embed, for a plain renderPlanHtml() caller with no live
|
|
453
|
+
// bundle nearby).
|
|
454
|
+
let plan = null, N = 0, blockEls = {}, step = 0, playing = false, animating = false;
|
|
455
|
+
|
|
361
456
|
const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
|
|
362
457
|
function drawState(i) {
|
|
363
458
|
for (const id of Object.keys(blockEls)) {
|
|
364
|
-
const p = posIn(
|
|
459
|
+
const p = posIn(plan.layouts[i], id);
|
|
365
460
|
if (!p) continue;
|
|
366
461
|
blockEls[id].style.transition = "none";
|
|
367
462
|
blockEls[id].style.left = p.x + "px";
|
|
@@ -370,7 +465,7 @@ const PLAN = ${embedded};
|
|
|
370
465
|
}
|
|
371
466
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
372
467
|
async function animateMove(i) {
|
|
373
|
-
const before =
|
|
468
|
+
const before = plan.layouts[i], after = plan.layouts[i + 1];
|
|
374
469
|
const movers = Object.keys(blockEls).filter((id) => {
|
|
375
470
|
const a = posIn(before, id), b = posIn(after, id);
|
|
376
471
|
return a && b && (a.x !== b.x || a.y !== b.y);
|
|
@@ -407,20 +502,22 @@ const PLAN = ${embedded};
|
|
|
407
502
|
}
|
|
408
503
|
const phaseFor = (i) => {
|
|
409
504
|
if (i >= N) return "done";
|
|
410
|
-
const ph =
|
|
505
|
+
const ph = plan.phases.find((p) => i >= p.from && i < p.to);
|
|
411
506
|
return ph ? ph.label : "";
|
|
412
507
|
};
|
|
413
508
|
function render() {
|
|
414
509
|
const phase = phaseFor(step);
|
|
415
510
|
stepLabel.textContent = "step " + step + " / " + N + (phase ? " · " + phase : "");
|
|
416
|
-
if (
|
|
511
|
+
if (plan.stepGoals) {
|
|
417
512
|
goalline.hidden = false;
|
|
418
513
|
goalline.textContent = step < N
|
|
419
|
-
? "Goal (inferred): " +
|
|
420
|
-
: "Goal (inferred): Goal reached" + (
|
|
514
|
+
? "Goal (inferred): " + plan.stepGoals[step]
|
|
515
|
+
: "Goal (inferred): Goal reached" + (plan.goalText ? " — " + plan.goalText : "") + " (" + N + " of " + N + " steps).";
|
|
516
|
+
} else {
|
|
517
|
+
goalline.hidden = true;
|
|
421
518
|
}
|
|
422
519
|
factsEl.innerHTML = "<b>board@step" + step + "</b> — " +
|
|
423
|
-
|
|
520
|
+
plan.facts[step].map((f) => f.replace(/&/g, "&").replace(/</g, "<")).join(" · ") +
|
|
424
521
|
' <span style="opacity:.7">(plan: findActionPath)</span>';
|
|
425
522
|
[...movelist.querySelectorAll("li:not(.phasehead)")].forEach((li, i) => {
|
|
426
523
|
li.classList.toggle("done", i < step);
|
|
@@ -441,26 +538,61 @@ const PLAN = ${embedded};
|
|
|
441
538
|
while (playing && step < to) { await forward(); if (step < to) await wait(300); }
|
|
442
539
|
playing = false; render();
|
|
443
540
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
541
|
+
|
|
542
|
+
/** (Re)mount the board/movelist from a planToPageData-shaped object —
|
|
543
|
+
* called once at boot with the server-embedded PLAN, and again after
|
|
544
|
+
* every successful live re-solve. */
|
|
545
|
+
function mountPlan(data) {
|
|
546
|
+
plan = data;
|
|
547
|
+
N = plan.actions.length;
|
|
548
|
+
step = 0; playing = false; animating = false;
|
|
549
|
+
board.innerHTML = ""; movelist.innerHTML = ""; blockEls = {};
|
|
550
|
+
|
|
551
|
+
const base = document.createElement("div"); base.className = "base"; board.appendChild(base);
|
|
552
|
+
for (const a of plan.anchors) {
|
|
553
|
+
if (a.kind === "slot") {
|
|
554
|
+
const post = document.createElement("div"); post.className = "post";
|
|
555
|
+
post.style.left = (a.x - 3) + "px"; board.appendChild(post);
|
|
556
|
+
} else {
|
|
557
|
+
const c = document.createElement("div"); c.className = "circle";
|
|
558
|
+
c.style.left = a.x + "px"; c.style.top = a.y + "px"; board.appendChild(c);
|
|
559
|
+
}
|
|
560
|
+
const lab = document.createElement("div");
|
|
561
|
+
lab.className = a.kind === "slot" ? "anchorlabel" : "circlelabel";
|
|
562
|
+
lab.textContent = a.id; lab.style.left = a.x + "px";
|
|
563
|
+
if (a.kind !== "slot") lab.style.top = (a.y + 4) + "px";
|
|
564
|
+
board.appendChild(lab);
|
|
458
565
|
}
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
566
|
+
for (const item of plan.layouts[0].items) {
|
|
567
|
+
if (item.kind !== "block") continue;
|
|
568
|
+
const el = document.createElement("div");
|
|
569
|
+
el.className = "block"; el.textContent = item.id;
|
|
570
|
+
el.style.width = item.w + "px"; el.style.background = item.fill;
|
|
571
|
+
board.appendChild(el); blockEls[item.id] = el;
|
|
572
|
+
}
|
|
573
|
+
plan.labels.forEach((label, i) => {
|
|
574
|
+
const ph = plan.phases.find((p) => p.from === i);
|
|
575
|
+
if (ph) {
|
|
576
|
+
const head = document.createElement("li");
|
|
577
|
+
head.className = "phasehead"; head.tabIndex = 0;
|
|
578
|
+
head.setAttribute("role", "button");
|
|
579
|
+
head.setAttribute("aria-label", "Play phase: " + ph.label);
|
|
580
|
+
head.textContent = ph.label + " · " + (ph.from + 1) + (ph.to - ph.from > 1 ? "–" + ph.to : "");
|
|
581
|
+
const go = () => playRange(ph.from, ph.to);
|
|
582
|
+
head.addEventListener("click", go);
|
|
583
|
+
head.addEventListener("keydown", (e) => {
|
|
584
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); }
|
|
585
|
+
});
|
|
586
|
+
movelist.appendChild(head);
|
|
587
|
+
}
|
|
588
|
+
const li = document.createElement("li");
|
|
589
|
+
li.textContent = (i + 1) + ". " + label;
|
|
590
|
+
li.addEventListener("click", () => { if (animating) return; playing = false; step = i + 1; drawState(step); render(); });
|
|
591
|
+
movelist.appendChild(li);
|
|
592
|
+
});
|
|
593
|
+
drawState(0); render();
|
|
594
|
+
}
|
|
595
|
+
|
|
464
596
|
btn.next.addEventListener("click", () => { playing = false; forward(); });
|
|
465
597
|
btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
|
|
466
598
|
btn.reset.addEventListener("click", () => { if (animating) return; playing = false; step = 0; drawState(0); render(); });
|
|
@@ -470,7 +602,132 @@ const PLAN = ${embedded};
|
|
|
470
602
|
if (step === N) { step = 0; drawState(0); }
|
|
471
603
|
await playRange(step, N);
|
|
472
604
|
});
|
|
473
|
-
|
|
605
|
+
|
|
606
|
+
mountPlan(PLAN);
|
|
607
|
+
|
|
608
|
+
// ---- live re-solve: disk-count/max-depth controls + the chat-assert dock
|
|
609
|
+
// over the sibling plan-browser.bundle.js (window.tmctPlan). Degrades
|
|
610
|
+
// honestly when the bundle failed to load or wasn't built alongside this
|
|
611
|
+
// page (e.g. a plain renderPlanHtml() call with no bundle nearby) — the
|
|
612
|
+
// baked-in replay above already stands on its own either way.
|
|
613
|
+
const liveStatusEl = document.getElementById("liveStatus");
|
|
614
|
+
const diskCountEl = document.getElementById("diskCount");
|
|
615
|
+
const maxDepthEl = document.getElementById("maxDepth");
|
|
616
|
+
const resolveBtn = document.getElementById("resolveBtn");
|
|
617
|
+
const chatlogEl = document.getElementById("chatlog");
|
|
618
|
+
const chatformEl = document.getElementById("chatform");
|
|
619
|
+
const chatqEl = document.getElementById("chatq");
|
|
620
|
+
const chatpillsEl = document.getElementById("chatpills");
|
|
621
|
+
|
|
622
|
+
const liveAvailable = typeof tmctPlan !== "undefined" && typeof tmctPlan.createPlanSession === "function";
|
|
623
|
+
if (!liveAvailable) {
|
|
624
|
+
liveStatusEl.textContent = "live re-solve unavailable here — showing the baked-in replay only.";
|
|
625
|
+
resolveBtn.disabled = true;
|
|
626
|
+
} else {
|
|
627
|
+
let session = null;
|
|
628
|
+
// Every engine-touching call (a resolve click and a chat submit) shares
|
|
629
|
+
// one in-memory session — serialize them, the same posture spider-fly-
|
|
630
|
+
// viz.mjs's own withLock takes for its ticker/chat-dock pair.
|
|
631
|
+
let lock = Promise.resolve();
|
|
632
|
+
const withLock = (fn) => { const run = lock.then(fn, fn); lock = run.catch(() => {}); return run; };
|
|
633
|
+
|
|
634
|
+
// The hanoi lesson's own "moving a disk onto a target makes the disk
|
|
635
|
+
// rest on the target" sentence needs a real lemmatiser to reduce
|
|
636
|
+
// "moving" to "move" — without it that one teach sentence honestly
|
|
637
|
+
// declines and every position fact taught after it fails in turn. Load
|
|
638
|
+
// wink from the CDN and register it, the SAME bounded-race pattern
|
|
639
|
+
// public/chat-ui.mjs and public/tmct-browser.mjs both use: a cross-
|
|
640
|
+
// origin dynamic import() can neither resolve nor reject on some
|
|
641
|
+
// failures, so an unbounded await would leave a resolve stuck forever.
|
|
642
|
+
// Awaited before EVERY session creation below (idempotent — a second
|
|
643
|
+
// await after the first attempt already settled resolves immediately).
|
|
644
|
+
const WINK_LOAD_TIMEOUT_MS = 8000;
|
|
645
|
+
const winkTimeout = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
|
|
646
|
+
let winkReady = null;
|
|
647
|
+
function tryLoadWink() {
|
|
648
|
+
if (winkReady) return winkReady;
|
|
649
|
+
winkReady = (async () => {
|
|
650
|
+
try {
|
|
651
|
+
const [{ default: winkNLP }, { default: model }] = await Promise.race([
|
|
652
|
+
Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
|
|
653
|
+
winkTimeout(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
|
|
654
|
+
]);
|
|
655
|
+
tmctPlan.registerWinkModel(() => ({ winkNLP, model }));
|
|
656
|
+
} catch (err) {
|
|
657
|
+
// eslint-disable-next-line no-console
|
|
658
|
+
console.warn("tmct plan: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
|
|
659
|
+
}
|
|
660
|
+
})();
|
|
661
|
+
return winkReady;
|
|
662
|
+
}
|
|
663
|
+
tryLoadWink(); // fire eagerly at load, so it is likely settled by the first interaction
|
|
664
|
+
|
|
665
|
+
function addChatLine(cls, text) {
|
|
666
|
+
const d = document.createElement("div");
|
|
667
|
+
d.className = cls; d.textContent = text;
|
|
668
|
+
chatlogEl.appendChild(d); chatlogEl.scrollTop = chatlogEl.scrollHeight;
|
|
669
|
+
}
|
|
670
|
+
function applyPlan(freshPlan) {
|
|
671
|
+
if (!freshPlan) return;
|
|
672
|
+
const { rendersAs, sizeOrder } = tmctPlan.renderInputsFromPlan(freshPlan);
|
|
673
|
+
mountPlan(tmctPlan.planToPageData({ plan: freshPlan, rendersAs, sizeOrder }));
|
|
674
|
+
if (pddlEl) pddlEl.textContent = tmctPlan.planToPddl(freshPlan);
|
|
675
|
+
// The <title>/<h1> were baked from the INITIAL plan's own goal text —
|
|
676
|
+
// a live re-solve toward a different goal (a fresh puzzle, or a
|
|
677
|
+
// taught goal revision) must not leave them stating the old one.
|
|
678
|
+
const freshTitle = freshPlan.goal?.text || pageTitleEl.textContent;
|
|
679
|
+
pageTitleEl.textContent = freshTitle;
|
|
680
|
+
document.title = freshTitle;
|
|
681
|
+
}
|
|
682
|
+
async function ensureSession() {
|
|
683
|
+
if (session) return session;
|
|
684
|
+
await tryLoadWink();
|
|
685
|
+
session = await tmctPlan.createPlanSession({
|
|
686
|
+
diskCount: Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3)),
|
|
687
|
+
maxDepth: Math.max(1, parseInt(maxDepthEl.value, 10) || 300),
|
|
688
|
+
});
|
|
689
|
+
return session;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
resolveBtn.addEventListener("click", () => withLock(async () => {
|
|
693
|
+
resolveBtn.disabled = true;
|
|
694
|
+
const n = Math.max(1, Math.min(7, parseInt(diskCountEl.value, 10) || 3));
|
|
695
|
+
const d = Math.max(1, parseInt(maxDepthEl.value, 10) || 300);
|
|
696
|
+
diskCountEl.value = n; maxDepthEl.value = d;
|
|
697
|
+
liveStatusEl.textContent = "solving a " + n + "-disk puzzle…";
|
|
698
|
+
await tryLoadWink();
|
|
699
|
+
session = await tmctPlan.createPlanSession({ diskCount: n, maxDepth: d });
|
|
700
|
+
if (session.plan) {
|
|
701
|
+
applyPlan(session.plan);
|
|
702
|
+
liveStatusEl.textContent = "live — " + n + " disk" + (n === 1 ? "" : "s") + ", max depth " + d + ".";
|
|
703
|
+
} else {
|
|
704
|
+
liveStatusEl.textContent = "no plan found within " + d + " moves — raise max search depth and try again.";
|
|
705
|
+
}
|
|
706
|
+
resolveBtn.disabled = false;
|
|
707
|
+
}));
|
|
708
|
+
|
|
709
|
+
chatformEl.addEventListener("submit", (e) => {
|
|
710
|
+
e.preventDefault();
|
|
711
|
+
const q = chatqEl.value.trim();
|
|
712
|
+
if (!q) return;
|
|
713
|
+
chatqEl.value = "";
|
|
714
|
+
addChatLine("u", q);
|
|
715
|
+
withLock(async () => {
|
|
716
|
+
const s = await ensureSession();
|
|
717
|
+
const maxDepth = Math.max(1, parseInt(maxDepthEl.value, 10) || 300);
|
|
718
|
+
const result = await s.turn(q, { maxDepth });
|
|
719
|
+
addChatLine("a", result.answer);
|
|
720
|
+
if (result.plan) applyPlan(result.plan);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
for (const pill of chatpillsEl.querySelectorAll(".pill")) {
|
|
725
|
+
pill.addEventListener("click", () => {
|
|
726
|
+
chatqEl.value = pill.dataset.fill || "";
|
|
727
|
+
chatqEl.focus();
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
474
731
|
})();
|
|
475
732
|
</script>
|
|
476
733
|
</body>
|