@polycode-projects/the-mechanical-code-talker 2.7.12 → 2.7.13
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/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +1 -0
- package/package.json +1 -1
- package/src/domain/spider-fly-world.mjs +13 -0
- package/src/domain/sprite-map.mjs +49 -1
- package/src/services/adventure-autoplay.mjs +192 -0
- package/src/services/adventure-viz.mjs +262 -0
- package/src/services/spider-fly-viz.mjs +39 -9
- package/src/services/spider-fly.mjs +236 -68
- package/src/surfaces/web/adventure-browser-entry.mjs +88 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +1 -0
- package/src/surfaces/web/spider-fly-browser-entry.mjs +10 -8
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"generated": "by scripts/build-worlds-pack.mjs from corpus/worlds/src/",
|
|
4
4
|
"counts": {
|
|
5
5
|
"worlds": 2,
|
|
6
|
-
"facts":
|
|
6
|
+
"facts": 528,
|
|
7
7
|
"rules": 19
|
|
8
8
|
},
|
|
9
9
|
"budgets": {
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"sources": [
|
|
15
15
|
{
|
|
16
16
|
"file": "src/ashcombe-hall.jsonl",
|
|
17
|
-
"bytes":
|
|
18
|
-
"sha256": "
|
|
17
|
+
"bytes": 8458,
|
|
18
|
+
"sha256": "79332b0700a8c8289ca4d8b5a9cf3dcff4c869fb1b45a72dd291617f61bdd184"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"file": "src/spider-fly.jsonl",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"file": "shards/ashcombe-hall.jsonl.gz",
|
|
34
|
-
"bytes":
|
|
35
|
-
"sha256": "
|
|
34
|
+
"bytes": 963,
|
|
35
|
+
"sha256": "d44e229d8f30dc43e8801443d426c315b43a6758adb11ad332c84e7108268746"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"file": "shards/spider-fly.jsonl.gz",
|
|
Binary file
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
{"world":"ashcombe-hall","kind":"fact","subject":"portrait","predicate":"mgx:fixed-in","object":"drawing-room"}
|
|
34
34
|
{"world":"ashcombe-hall","kind":"fact","subject":"key","predicate":"mgx:hidden-in","object":"portrait"}
|
|
35
35
|
{"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:hidden-in","object":"cabinet"}
|
|
36
|
+
{"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:is-objective","object":"true"}
|
|
36
37
|
{"world":"ashcombe-hall","kind":"fact","subject":"housekeeper","predicate":"mgx:currently-in","object":"kitchen"}
|
|
37
38
|
{"world":"ashcombe-hall","kind":"fact","subject":"cook","predicate":"mgx:currently-in","object":"kitchen"}
|
|
38
39
|
{"world":"ashcombe-hall","kind":"fact","subject":"butler","predicate":"mgx:currently-in","object":"drawing-room"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -16,6 +16,19 @@ export const GRID_SIZE = 10;
|
|
|
16
16
|
export const WEB_HOME = Object.freeze({ x: 2, y: 2 });
|
|
17
17
|
export const WEB_RADIUS = 1;
|
|
18
18
|
|
|
19
|
+
// A spider-built dynamic web (src/services/spider-fly.mjs's hasActiveWebAt)
|
|
20
|
+
// stays active for this many turns past the turn it was built, mirroring the
|
|
21
|
+
// static home zone's own always-on web without needing separate code paths.
|
|
22
|
+
export const WEB_DURATION_TURNS = 10;
|
|
23
|
+
|
|
24
|
+
// Spider mass mirrors a fly's own (src/services/spider-fly.mjs's
|
|
25
|
+
// FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
|
|
26
|
+
// does, and gains exactly a fly's remaining mass on an eat. Heavier starting
|
|
27
|
+
// mass than a single fly's worth on purpose — a spider that eats nothing for
|
|
28
|
+
// a while has some runway before starving.
|
|
29
|
+
export const SPIDER_INITIAL_MASS = 15;
|
|
30
|
+
export const SPIDER_MASS_DECREMENT_PER_TURN = 1;
|
|
31
|
+
|
|
19
32
|
export const cellId = (x, y) => `cell-${x}-${y}`;
|
|
20
33
|
|
|
21
34
|
const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
|
|
@@ -79,11 +79,53 @@ const ANIMAL_SVG =
|
|
|
79
79
|
+ '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="7" y1="17" x2="7" y2="21"/><line x1="17" y1="17" x2="17" y2="21"/></g>'
|
|
80
80
|
+ "</svg>";
|
|
81
81
|
|
|
82
|
+
// ---- the adventure world's own class family (PLAN_GAMES_UPLIFT_V2.md Part B)
|
|
83
|
+
// — a room's floor plan, a piece of furniture, a portable, a person, the
|
|
84
|
+
// player's own adventurer sprite, and a container. Ashcombe Hall's own
|
|
85
|
+
// classes resolve to these directly (no ancestor walk needed, since the
|
|
86
|
+
// shipped world has no rdfs:subClassOf chain at all yet); the walk stays
|
|
87
|
+
// ready for a future world whose taxonomy goes deeper, the same "poodle IsA
|
|
88
|
+
// dog" mechanism spider-fly already exercises.
|
|
89
|
+
|
|
90
|
+
const ROOM_SVG =
|
|
91
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/>'
|
|
92
|
+
+ '<path d="M9 21 L9 13 A3 3 0 0 1 15 13 L15 21" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
93
|
+
|
|
94
|
+
const FURNITURE_SVG =
|
|
95
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="3" rx="1" fill="currentColor"/>'
|
|
96
|
+
+ '<rect x="4" y="8" width="3" height="12" fill="currentColor"/>'
|
|
97
|
+
+ '<rect x="17" y="8" width="3" height="12" fill="currentColor"/></svg>';
|
|
98
|
+
|
|
99
|
+
const PORTABLE_SVG =
|
|
100
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="1.5" fill="currentColor"/>'
|
|
101
|
+
+ '<path d="M9 9 V6.5 A3 3 0 0 1 15 6.5 V9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
102
|
+
|
|
103
|
+
const PERSON_SVG =
|
|
104
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
|
|
105
|
+
+ '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/></svg>';
|
|
106
|
+
|
|
107
|
+
// The adventurer is the player's own sprite — the same silhouette as `person`
|
|
108
|
+
// plus a small satchel, so the one individual the player controls always
|
|
109
|
+
// reads as visually distinct from the cast of NPCs sharing the room.
|
|
110
|
+
const ADVENTURER_SVG =
|
|
111
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
|
|
112
|
+
+ '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/>'
|
|
113
|
+
+ '<rect x="15.5" y="13.5" width="4.5" height="5" rx="1" fill="currentColor" opacity="0.6"/></svg>';
|
|
114
|
+
|
|
115
|
+
const CONTAINER_SVG =
|
|
116
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="10" width="18" height="10" rx="1.2" fill="currentColor"/>'
|
|
117
|
+
+ '<path d="M3 10 L5 5 H19 L21 10 Z" fill="currentColor" opacity="0.7"/>'
|
|
118
|
+
+ '<rect x="10.6" y="9.4" width="2.8" height="2.2" rx="0.4" fill="currentColor" opacity="0.35"/></svg>';
|
|
119
|
+
|
|
82
120
|
/** The sprite registry: class name (normFactTerm-normalized) -> inline SVG
|
|
83
121
|
* markup. `animal` is this world's declared root fallback (PLAN_SPIDER_FLY.md
|
|
84
122
|
* §7 names "animal"/"object"/"plant" as the family of possible roots — a
|
|
85
123
|
* spider-and-fly board only ever needs "animal") — resolveSpriteForClass
|
|
86
|
-
* falls back to it once the ancestor walk exhausts with no closer hit.
|
|
124
|
+
* falls back to it once the ancestor walk exhausts with no closer hit. The
|
|
125
|
+
* adventure world's classes (room/furniture/portable/person/adventurer/
|
|
126
|
+
* container) are a second, unrelated root family sharing the one flat table —
|
|
127
|
+
* a board only ever resolves classes from its own world, so the two never
|
|
128
|
+
* collide in practice. */
|
|
87
129
|
export const SPRITE_REGISTRY = Object.freeze({
|
|
88
130
|
spider: SPIDER_SVG,
|
|
89
131
|
fly: FLY_SVG,
|
|
@@ -91,6 +133,12 @@ export const SPRITE_REGISTRY = Object.freeze({
|
|
|
91
133
|
poodle: POODLE_SVG,
|
|
92
134
|
dog: DOG_SVG,
|
|
93
135
|
animal: ANIMAL_SVG,
|
|
136
|
+
room: ROOM_SVG,
|
|
137
|
+
furniture: FURNITURE_SVG,
|
|
138
|
+
portable: PORTABLE_SVG,
|
|
139
|
+
person: PERSON_SVG,
|
|
140
|
+
adventurer: ADVENTURER_SVG,
|
|
141
|
+
container: CONTAINER_SVG,
|
|
94
142
|
});
|
|
95
143
|
|
|
96
144
|
/** Every direct rdfs:subClassOf superclass of `term`, from a flat fact-row
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// adventure-autoplay.mjs — a sibling layer over adventure.mjs (never edited
|
|
2
|
+
// for this: this session's own in-flight rule-shape work owns that file)
|
|
3
|
+
// that plays a loaded world by itself: infer a goal from one generic marker
|
|
4
|
+
// fact, explore toward it, fetch it, and report an honest stall the moment no
|
|
5
|
+
// further move is justified. It is a CALLER of the existing command
|
|
6
|
+
// interpreter (adventureTurn), never a second one — every move it makes is
|
|
7
|
+
// the same plain command string a human types ("go north", "take letter"),
|
|
8
|
+
// executed through the identical turn a real chat session runs.
|
|
9
|
+
//
|
|
10
|
+
// The one hard constraint: auto-play only ever reasons over what it has
|
|
11
|
+
// actually seen. `exposedRoomIds` is the set of rooms this auto-play run has
|
|
12
|
+
// itself moved into (the opening room included from turn 0) — it is the sole
|
|
13
|
+
// caller making every move, so it always knows this, and threads the set
|
|
14
|
+
// forward turn to turn exactly like spider-fly.mjs threads its own agents
|
|
15
|
+
// shape. `exposedFacts` turns that set into the actual filtered view: a fact
|
|
16
|
+
// is exposed when its subject's CURRENT placement resolves into an exposed
|
|
17
|
+
// room, when the subject is the player, or when the fact IS the world's
|
|
18
|
+
// objective marker (told to the player unconditionally at turn 0, the same
|
|
19
|
+
// way the opening line's prose already is). A hidden object's reveal falls
|
|
20
|
+
// out of this automatically — no `mgx:hidden-in` special case is needed, the
|
|
21
|
+
// placement-resolution walk already returns null for anything still hidden.
|
|
22
|
+
//
|
|
23
|
+
// The objective marker is one new, generic world-pack fact,
|
|
24
|
+
// `{"subject":"letter","predicate":"mgx:is-objective","object":"true"}` —
|
|
25
|
+
// deliberately not hard-coded to any one world. A world that ships no such
|
|
26
|
+
// fact simply has nothing for this module to infer a goal toward.
|
|
27
|
+
import { findActionPath } from "../domain/planning.mjs";
|
|
28
|
+
import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
|
|
29
|
+
import { foldWorldState, adventureTurn } from "./adventure.mjs";
|
|
30
|
+
|
|
31
|
+
const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
|
|
32
|
+
const baseSubjectOf = (subject) => SNAPSHOT_RE.exec(subject)?.[1] ?? subject;
|
|
33
|
+
|
|
34
|
+
const isTypedRow = (rows, subject, type) =>
|
|
35
|
+
(rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
|
|
36
|
+
|
|
37
|
+
/** The room a subject's CURRENT placement resolves into — a room resolves to
|
|
38
|
+
* itself; an object placed directly in a room resolves to that room; an
|
|
39
|
+
* object one containment hop inside an OPEN container resolves to the
|
|
40
|
+
* container's own room; anything hidden, carried, or inside a closed
|
|
41
|
+
* container resolves to nothing (null). This mirrors adventure.mjs's own
|
|
42
|
+
* private `visibleRoomOf` exactly (that helper isn't exported, so the walk
|
|
43
|
+
* is re-derived here against the same `foldWorldState` placements map,
|
|
44
|
+
* never a second notion of visibility). */
|
|
45
|
+
function roomOfSubject(subject, rows, state) {
|
|
46
|
+
if (isTypedRow(rows, subject, "room")) return subject;
|
|
47
|
+
const place = state.placements.get(subject);
|
|
48
|
+
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
49
|
+
if (place.predicate === "mgx:currently-in" || isTypedRow(rows, place.object, "room")) return place.object;
|
|
50
|
+
const holder = place.object;
|
|
51
|
+
if (holder === "player") return null; // carried, not resolvable to a room
|
|
52
|
+
if (!state.openness.get(holder)?.open) return null;
|
|
53
|
+
const holderPlace = state.placements.get(holder);
|
|
54
|
+
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The subset of `allRows` this auto-play run may reason over, given the
|
|
59
|
+
* rooms it has actually moved into so far (`exposedRoomIds`). Pure — folds
|
|
60
|
+
* `allRows` itself rather than taking a precomputed state, so a caller never
|
|
61
|
+
* has to keep a fold in step with the exposure set by hand.
|
|
62
|
+
*/
|
|
63
|
+
export function exposedFacts(allRows, exposedRoomIds) {
|
|
64
|
+
const rows = allRows || [];
|
|
65
|
+
const state = foldWorldState(rows);
|
|
66
|
+
const exposed = new Set(exposedRoomIds || []);
|
|
67
|
+
return rows.filter((row) => {
|
|
68
|
+
if (baseSubjectOf(row.subject) === "player") return true;
|
|
69
|
+
if (row.predicate === "mgx:is-objective") return true;
|
|
70
|
+
const room = roomOfSubject(baseSubjectOf(row.subject), rows, state);
|
|
71
|
+
return room != null && exposed.has(room);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One hop per exposed room's own has-exit-* facts, sorted by direction name
|
|
76
|
+
* for deterministic tie-breaking — the applyActions closure findActionPath
|
|
77
|
+
* needs, built from the EXPOSED fold only, so a search over this graph can
|
|
78
|
+
* never plan through an edge auto-play hasn't itself walked into. */
|
|
79
|
+
function exposedExitApplyActions(exposedState) {
|
|
80
|
+
return (room) => {
|
|
81
|
+
const dirs = exposedState.exits.get(room);
|
|
82
|
+
if (!dirs) return [];
|
|
83
|
+
return [...dirs.keys()].sort().map((direction) => ({ action: direction, nextState: dirs.get(direction) }));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const unexposedExitsOf = (room, exposedState, exposed) =>
|
|
88
|
+
[...(exposedState.exits.get(room)?.entries() ?? [])].filter(([, target]) => !exposed.has(target));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One auto-play tick over a live, loaded adventure: fold the world, infer a
|
|
92
|
+
* goal from the one generic objective marker under the exposure constraint,
|
|
93
|
+
* and execute exactly the one move that goal implies through `adventureTurn`
|
|
94
|
+
* — the same public entry point a real chat turn calls. Returns
|
|
95
|
+
* `{ turn, goal, plan, done, stalled, exposedRoomIds }`, mirroring
|
|
96
|
+
* spider-fly.mjs's own tick shape: `plan` is the remaining multi-step route a
|
|
97
|
+
* `findActionPath` search found this tick (or null when the move was a
|
|
98
|
+
* single, immediate step — an adjacent unexposed exit, or a take), `goal` is
|
|
99
|
+
* a short line describing what this tick did, `done` means the objective is
|
|
100
|
+
* now carried, `stalled` means no move could be justified without guessing.
|
|
101
|
+
*/
|
|
102
|
+
export async function runAdventureAutoplayTick(memoryDir, opts = {}) {
|
|
103
|
+
const { exposedRoomIds, planHolder, sessionId = "", env = {}, graph = null, cache = null } = opts;
|
|
104
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
105
|
+
const state = foldWorldState(rows);
|
|
106
|
+
const here = state.placements.get("player")?.object ?? null;
|
|
107
|
+
const exposed = new Set(exposedRoomIds || []);
|
|
108
|
+
|
|
109
|
+
if (!here) {
|
|
110
|
+
return {
|
|
111
|
+
turn: state.turnCount, goal: "stalled — no player position is written for this world.",
|
|
112
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
exposed.add(here);
|
|
116
|
+
|
|
117
|
+
const runCommand = (line) => adventureTurn(line, { planHolder, memoryDir, sessionId, env, graph, cache });
|
|
118
|
+
|
|
119
|
+
const exposedRows = exposedFacts(rows, exposed);
|
|
120
|
+
const exposedState = foldWorldState(exposedRows);
|
|
121
|
+
const objectiveId = exposedRows.find((r) => r.predicate === "mgx:is-objective" && r.object === "true")?.subject ?? null;
|
|
122
|
+
|
|
123
|
+
// Win: the objective is already carried — checked against the FULL fold,
|
|
124
|
+
// since what the player itself carries is always self-evidently known,
|
|
125
|
+
// the same unconditional "OR the subject is player" exposure the marker
|
|
126
|
+
// fact itself gets (worldDigestRows shows carried items regardless of
|
|
127
|
+
// room visibility too — carrying was never gated on being seen).
|
|
128
|
+
const carried = objectiveId
|
|
129
|
+
&& state.placements.get(objectiveId)?.predicate === "mgx:located-in"
|
|
130
|
+
&& state.placements.get(objectiveId)?.object === "player";
|
|
131
|
+
if (objectiveId && carried) {
|
|
132
|
+
return {
|
|
133
|
+
turn: state.turnCount, goal: `carrying the ${objectiveId} — the adventure is won.`,
|
|
134
|
+
plan: [], done: true, stalled: false, exposedRoomIds: exposed,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const objectiveRoom = objectiveId ? roomOfSubject(objectiveId, exposedRows, exposedState) : null;
|
|
139
|
+
|
|
140
|
+
if (objectiveId && objectiveRoom) {
|
|
141
|
+
if (objectiveRoom === here) {
|
|
142
|
+
await runCommand(`take ${objectiveId}`);
|
|
143
|
+
return {
|
|
144
|
+
turn: state.turnCount + 1, goal: `in the ${here} — taking the ${objectiveId}.`,
|
|
145
|
+
plan: null, done: false, stalled: false, exposedRoomIds: exposed,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const path = findActionPath(here, (room) => room === objectiveRoom, exposedExitApplyActions(exposedState));
|
|
149
|
+
if (!path || !path.actions.length) {
|
|
150
|
+
return {
|
|
151
|
+
turn: state.turnCount, goal: `stalled — no seen path from the ${here} to the ${objectiveRoom}.`,
|
|
152
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
await runCommand(`go ${path.actions[0]}`);
|
|
156
|
+
exposed.add(path.states[1]);
|
|
157
|
+
return {
|
|
158
|
+
turn: state.turnCount + 1, goal: `heading toward the ${objectiveRoom} for the ${objectiveId}.`,
|
|
159
|
+
plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Explore: the objective either doesn't exist in this world, or its room
|
|
164
|
+
// isn't known yet. Prefer an immediate unexposed exit from here (the
|
|
165
|
+
// lowest-sorted direction); otherwise path toward the nearest exposed room
|
|
166
|
+
// that still has one.
|
|
167
|
+
const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => a.localeCompare(b));
|
|
168
|
+
if (unexposedHere.length) {
|
|
169
|
+
const [direction, target] = unexposedHere[0];
|
|
170
|
+
await runCommand(`go ${direction}`);
|
|
171
|
+
exposed.add(target);
|
|
172
|
+
return {
|
|
173
|
+
turn: state.turnCount + 1, goal: `exploring — heading ${direction} into unseen ground.`,
|
|
174
|
+
plan: null, done: false, stalled: false, exposedRoomIds: exposed,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hasUnexposedExit = (room) => unexposedExitsOf(room, exposedState, exposed).length > 0;
|
|
179
|
+
const path = findActionPath(here, hasUnexposedExit, exposedExitApplyActions(exposedState));
|
|
180
|
+
if (!path || !path.actions.length) {
|
|
181
|
+
return {
|
|
182
|
+
turn: state.turnCount, goal: "stalled — every reachable room is already explored, and no goal was ever found.",
|
|
183
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
await runCommand(`go ${path.actions[0]}`);
|
|
187
|
+
exposed.add(path.states[1]);
|
|
188
|
+
return {
|
|
189
|
+
turn: state.turnCount + 1, goal: "exploring — backtracking toward unseen ground.",
|
|
190
|
+
plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// adventure-viz.mjs — the adventure's own full-screen/home-page hero
|
|
2
|
+
// (PLAN_GAMES_UPLIFT_V2.md Part B), styled directly after
|
|
3
|
+
// spider-fly-viz.mjs's own self-contained page-builder: one inlined <style>
|
|
4
|
+
// importing viz-theme.mjs's shared tokens, behaviour as an inlined IIFE, the
|
|
5
|
+
// shared `createTicker` primitive spliced in via `.toString()` exactly the
|
|
6
|
+
// way that page splices its own render-glue helpers.
|
|
7
|
+
//
|
|
8
|
+
// Where this page's data-loading DIVERGES from spider-fly's, on purpose:
|
|
9
|
+
// spider-fly-world.mjs is itself a plain, dependency-free JS module (no I/O),
|
|
10
|
+
// so its browser entry can call it directly to bootstrap a board. Ashcombe
|
|
11
|
+
// Hall's canonical definition is a JSONL corpus source
|
|
12
|
+
// (corpus/worlds/src/ashcombe-hall.jsonl), read through a Node fs/gzip
|
|
13
|
+
// provider the browser cannot run. Rather than hand-duplicating the world as
|
|
14
|
+
// a second, hardcoded JS copy (exactly the kind of drift this project's
|
|
15
|
+
// worlds-pack build step exists to prevent), the real facts+rules are read
|
|
16
|
+
// ONCE at build time (scripts/build-demo-site.mjs, the same Node path
|
|
17
|
+
// test/services/adventure.test.mjs's own loadShippedWorldInto uses) and
|
|
18
|
+
// embedded into this page as plain JSON — the same posture ledger.html
|
|
19
|
+
// already takes with its own precomputed memory payload, just applied to a
|
|
20
|
+
// second kind of build-time data.
|
|
21
|
+
//
|
|
22
|
+
// Two pure, `.toString()`-splice-safe pieces are exported as real functions
|
|
23
|
+
// (not raw inline-script text) so they can be pinned directly by tests, the
|
|
24
|
+
// same discipline spider-fly-viz.mjs holds classOfAgentId/
|
|
25
|
+
// threadCellsForSpiderPlan to: `spriteClassForObject` (an object's sprite
|
|
26
|
+
// class, from its own rdf:type or mgx:is-container fact — NOT from
|
|
27
|
+
// adventure.mjs's private isContainer/isTyped, which this module cannot
|
|
28
|
+
// import without duplicating adventure.mjs's own closed vocabulary reading)
|
|
29
|
+
// and `roomSceneObjects` (every subject actually visible in a room, mirrored
|
|
30
|
+
// from adventure.mjs's private `visibleRoomOf` the same way
|
|
31
|
+
// adventure-autoplay.mjs's own `roomOfSubject` already has to). `roomCaptionText`
|
|
32
|
+
// is a third pure helper, exported for testing, but NOT spliced — it calls
|
|
33
|
+
// `worldDigestRows` via a real ES import, since Node/test callers have one,
|
|
34
|
+
// while the in-page script instead calls the browser bundle's own exposed
|
|
35
|
+
// copy (mirroring how the inline script calls `tmctSpiderFly.*` rather than
|
|
36
|
+
// re-importing spider-fly-world.mjs).
|
|
37
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
|
|
38
|
+
import { createTicker } from "./viz-ticker.mjs";
|
|
39
|
+
import { worldDigestRows } from "./adventure.mjs";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_TITLE = "tmct — the adventure";
|
|
42
|
+
const PREVIEW_MAX_TICKS = 30;
|
|
43
|
+
const TICK_WAIT_MS = 900;
|
|
44
|
+
|
|
45
|
+
/** An object's sprite class: `container` when it carries mgx:is-container
|
|
46
|
+
* (a distinct icon from plain furniture, since Ashcombe's own cabinet and
|
|
47
|
+
* portrait are typed "furniture" but read more clearly as a container on
|
|
48
|
+
* screen), else its own rdf:type object, else the generic "portable"
|
|
49
|
+
* fallback for anything a world places with no type fact at all. Pure,
|
|
50
|
+
* self-contained — no reference to adventure.mjs's own private isContainer/
|
|
51
|
+
* isTyped, since those aren't exported. */
|
|
52
|
+
export function spriteClassForObject(rows, subject) {
|
|
53
|
+
const isContainer = (rows || []).some(
|
|
54
|
+
(r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true",
|
|
55
|
+
);
|
|
56
|
+
if (isContainer) return "container";
|
|
57
|
+
const typeRow = (rows || []).find((r) => r.subject === subject && r.predicate === "rdf:type");
|
|
58
|
+
return typeRow ? typeRow.object : "portable";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every subject actually visible in `here`, sorted, each with its sprite
|
|
62
|
+
* class — mirroring adventure.mjs's own private `visibleRoomOf` walk (one
|
|
63
|
+
* containment hop through an OPEN container) so this can never draw a
|
|
64
|
+
* hidden or carried object the text digest wouldn't also mention. `player`
|
|
65
|
+
* is excluded; the caller draws the player's own adventurer sprite
|
|
66
|
+
* separately. Pure. */
|
|
67
|
+
export function roomSceneObjects(rows, state, here) {
|
|
68
|
+
const isTypedRoom = (subject) =>
|
|
69
|
+
(rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === "room");
|
|
70
|
+
const visibleRoomOf = (subject) => {
|
|
71
|
+
const place = state.placements.get(subject);
|
|
72
|
+
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
73
|
+
if (place.predicate === "mgx:currently-in" || isTypedRoom(place.object)) return place.object;
|
|
74
|
+
const holder = place.object;
|
|
75
|
+
if (holder === "player") return null;
|
|
76
|
+
if (!state.openness.get(holder)?.open) return null;
|
|
77
|
+
const holderPlace = state.placements.get(holder);
|
|
78
|
+
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
79
|
+
};
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const subject of [...state.placements.keys()].sort()) {
|
|
82
|
+
if (subject === "player") continue;
|
|
83
|
+
if (visibleRoomOf(subject) !== here) continue;
|
|
84
|
+
out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A short caption for `here`, built ONLY from the rows worldDigestRows
|
|
90
|
+
* itself already produces (the exact same view the chat reply's own digest
|
|
91
|
+
* reads) — every row already reads as a plain sentence
|
|
92
|
+
* (`${subject} ${predicate} ${object}.`), so this never invents a phrase
|
|
93
|
+
* the text digest doesn't already carry. Filters to rows about the room
|
|
94
|
+
* itself (its own exits) or about something placed IN it — the same
|
|
95
|
+
* "visible here" boundary `roomSceneObjects` draws from. The player's own
|
|
96
|
+
* "is in the" row is excluded: the room frame already IS the current room,
|
|
97
|
+
* so restating "you are here" is redundant, never informative. */
|
|
98
|
+
export function roomCaptionText(rows, state, here) {
|
|
99
|
+
const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
|
|
100
|
+
const lines = worldDigestRows(rows, state)
|
|
101
|
+
.filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
|
|
102
|
+
.map((row) => `${row.subject} ${row.predicate} ${row.object}.`);
|
|
103
|
+
return lines.length ? lines.join(" ") : `Nothing more about the ${here} is written down yet.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The self-contained adventure page. Pure given `worldPayload` (the build
|
|
107
|
+
* step's own read of the real Ashcombe Hall world — `{ facts, rules,
|
|
108
|
+
* opening }`), the same "byte-identical for identical input" invariant
|
|
109
|
+
* every other viz page in this project holds. `?preview=1` switches into
|
|
110
|
+
* the small, auto-playing, non-interactive mode the home page's hero iframe
|
|
111
|
+
* embeds, matching spider-fly.html's own dual-purpose file. */
|
|
112
|
+
export function renderAdventureHtml({ title = DEFAULT_TITLE, worldPayload = { facts: [], rules: [], opening: "" } } = {}) {
|
|
113
|
+
const pageData = embedJson({
|
|
114
|
+
world: worldPayload,
|
|
115
|
+
previewMaxTicks: PREVIEW_MAX_TICKS,
|
|
116
|
+
tickWaitMs: TICK_WAIT_MS,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return `<!doctype html>
|
|
120
|
+
<html lang="en">
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="utf-8">
|
|
123
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
124
|
+
<title>${escapeHtml(title)}</title>
|
|
125
|
+
<style>
|
|
126
|
+
${THEME_TOKENS_CSS}
|
|
127
|
+
html { background: var(--bg); }
|
|
128
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
|
|
129
|
+
.mono { font-family: ${MONO_STACK}; }
|
|
130
|
+
main { max-width: 860px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
|
|
131
|
+
.eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
|
132
|
+
h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
|
|
133
|
+
button { font: inherit; color: inherit; background: none; cursor: pointer; }
|
|
134
|
+
button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
135
|
+
.room-frame { position: relative; min-height: 210px; background: var(--taught-soft); border: 1px solid var(--line); padding: 1rem; display: flex; flex-direction: column; gap: .8rem; justify-content: flex-end; }
|
|
136
|
+
.sprite-row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end; }
|
|
137
|
+
.sprite { width: 44px; height: 44px; }
|
|
138
|
+
.sprite svg { width: 100%; height: 100%; display: block; }
|
|
139
|
+
.sprite[data-cls="adventurer"] { color: var(--taught); }
|
|
140
|
+
.sprite[data-cls="person"] { color: var(--corpus); }
|
|
141
|
+
.sprite[data-cls="container"], .sprite[data-cls="furniture"] { color: var(--entail); }
|
|
142
|
+
.sprite[data-cls="portable"] { color: var(--alert); }
|
|
143
|
+
.sprite[data-cls="room"] { color: var(--muted); }
|
|
144
|
+
.sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
|
|
145
|
+
.caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
|
|
146
|
+
.controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
|
|
147
|
+
.controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
|
|
148
|
+
.controls-row button:hover:not(:disabled) { border-color: var(--taught); }
|
|
149
|
+
.controls-row button:disabled { opacity: .4; cursor: default; }
|
|
150
|
+
.controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
151
|
+
.goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
|
|
152
|
+
.status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
|
|
153
|
+
body.preview .controls-row, body.preview .status { display: none; }
|
|
154
|
+
body.preview main { padding: 0; max-width: none; }
|
|
155
|
+
body.preview .eyebrow, body.preview h1 { display: none; }
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<main>
|
|
160
|
+
<div class="eyebrow">tmct · the adventure</div>
|
|
161
|
+
<h1>A room, drawn from exactly what the text already says is there</h1>
|
|
162
|
+
<div class="room-frame" id="roomFrame">
|
|
163
|
+
<div class="sprite-row" id="spriteRow"></div>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="caption" id="caption"></div>
|
|
166
|
+
<div class="goal-line" id="goalLine"></div>
|
|
167
|
+
<div class="controls-row">
|
|
168
|
+
<button id="resetBtn" type="button" disabled>reset</button>
|
|
169
|
+
<button id="playBtn" type="button" disabled>▶ play</button>
|
|
170
|
+
<button id="stepBtn" type="button" disabled>step</button>
|
|
171
|
+
<span class="turn mono" id="turnLabel">turn: 0</span>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="status" id="status">loading the engine…</div>
|
|
174
|
+
</main>
|
|
175
|
+
<script>
|
|
176
|
+
const ADVENTURE = ${pageData};
|
|
177
|
+
</script>
|
|
178
|
+
<script src="./adventure-browser.bundle.js"></script>
|
|
179
|
+
<script>
|
|
180
|
+
(function () {
|
|
181
|
+
"use strict";
|
|
182
|
+
const createTicker = ${createTicker.toString()};
|
|
183
|
+
const spriteClassForObject = ${spriteClassForObject.toString()};
|
|
184
|
+
const roomSceneObjects = ${roomSceneObjects.toString()};
|
|
185
|
+
const esc = ${escapeHtml.toString()};
|
|
186
|
+
const el = (id) => document.getElementById(id);
|
|
187
|
+
const spriteRow = el("spriteRow");
|
|
188
|
+
const captionEl = el("caption");
|
|
189
|
+
const goalLineEl = el("goalLine");
|
|
190
|
+
const statusEl = el("status");
|
|
191
|
+
const turnLabelEl = el("turnLabel");
|
|
192
|
+
const resetBtn = el("resetBtn");
|
|
193
|
+
const playBtn = el("playBtn");
|
|
194
|
+
const stepBtn = el("stepBtn");
|
|
195
|
+
|
|
196
|
+
const params = new URLSearchParams(location.search);
|
|
197
|
+
const preview = params.get("preview") === "1";
|
|
198
|
+
document.body.classList.toggle("preview", preview);
|
|
199
|
+
|
|
200
|
+
let session = null;
|
|
201
|
+
let lastTicks = 0;
|
|
202
|
+
|
|
203
|
+
function captionFor(rows, state, here) {
|
|
204
|
+
const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
|
|
205
|
+
const lines = tmctAdventure.worldDigestRows(rows, state)
|
|
206
|
+
.filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
|
|
207
|
+
.map((row) => row.subject + " " + row.predicate + " " + row.object + ".");
|
|
208
|
+
return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function redraw(snap) {
|
|
212
|
+
const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
|
|
213
|
+
const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
|
|
214
|
+
spriteRow.innerHTML = sprites.map((s) => {
|
|
215
|
+
const svg = tmctAdventure.resolveSpriteForClass(s.spriteClass, [], tmctAdventure.SPRITE_REGISTRY);
|
|
216
|
+
return '<div><div class="sprite" data-cls="' + esc(s.spriteClass) + '">' + svg + '</div>'
|
|
217
|
+
+ '<div class="sprite-label">' + esc(s.subject) + "</div></div>";
|
|
218
|
+
}).join("");
|
|
219
|
+
captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
|
|
220
|
+
turnLabelEl.textContent = "turn: " + snap.turn;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function boot() {
|
|
224
|
+
session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
|
|
225
|
+
lastTicks = 0;
|
|
226
|
+
const snap = await session.snapshot();
|
|
227
|
+
redraw(snap);
|
|
228
|
+
goalLineEl.textContent = "";
|
|
229
|
+
statusEl.textContent = ADVENTURE.world.opening || "";
|
|
230
|
+
resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ticker = createTicker({
|
|
234
|
+
onTick: async () => {
|
|
235
|
+
const result = await session.autoplayTick();
|
|
236
|
+
lastTicks += 1;
|
|
237
|
+
const snap = await session.snapshot();
|
|
238
|
+
redraw(snap);
|
|
239
|
+
goalLineEl.textContent = result.goal || "";
|
|
240
|
+
if (result.done || result.stalled) ticker.pause();
|
|
241
|
+
},
|
|
242
|
+
onRender: (state) => {
|
|
243
|
+
playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
|
|
244
|
+
playBtn.disabled = state.animating;
|
|
245
|
+
stepBtn.disabled = state.animating || state.playing;
|
|
246
|
+
resetBtn.disabled = state.animating;
|
|
247
|
+
},
|
|
248
|
+
onReset: () => boot(),
|
|
249
|
+
hasNext: () => !preview || lastTicks < ADVENTURE.previewMaxTicks,
|
|
250
|
+
waitMs: ADVENTURE.tickWaitMs,
|
|
251
|
+
});
|
|
252
|
+
playBtn.addEventListener("click", () => ticker.play());
|
|
253
|
+
stepBtn.addEventListener("click", () => ticker.stepOnce());
|
|
254
|
+
resetBtn.addEventListener("click", () => ticker.reset());
|
|
255
|
+
|
|
256
|
+
boot().then(() => { if (preview) ticker.play(); });
|
|
257
|
+
})();
|
|
258
|
+
</script>
|
|
259
|
+
</body>
|
|
260
|
+
</html>
|
|
261
|
+
`;
|
|
262
|
+
}
|