@polycode-projects/the-mechanical-code-talker 5.0.2 → 5.0.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.
Files changed (37) hide show
  1. package/corpus/worlds/manifest.json +9 -9
  2. package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
  3. package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
  4. package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
  5. package/corpus/worlds/src/town-square-chapel.jsonl +1 -1
  6. package/corpus/worlds/src/town-square-market.jsonl +1 -1
  7. package/corpus/worlds/src/town-square.jsonl +1 -1
  8. package/data/mudiii-assets.json +35 -5
  9. package/package.json +1 -1
  10. package/src/adapters/memory/core.mjs +236 -18
  11. package/src/domain/ask-vocab.mjs +1 -1
  12. package/src/domain/ask.mjs +20 -10
  13. package/src/domain/game-config.mjs +10 -1
  14. package/src/domain/interpret/normalize.mjs +4 -0
  15. package/src/domain/memory/retraction.mjs +232 -0
  16. package/src/domain/p2p/sync-filter.mjs +9 -1
  17. package/src/domain/spider-fly-world.mjs +80 -35
  18. package/src/domain/syllogise.mjs +128 -75
  19. package/src/services/adventure-autoplay.mjs +2 -2
  20. package/src/services/adventure-viz.mjs +12 -7
  21. package/src/services/chat.mjs +42 -14
  22. package/src/services/mud-turn.mjs +1 -1
  23. package/src/services/mud-viz.mjs +11 -2
  24. package/src/services/mudiii-scene.mjs +199 -55
  25. package/src/services/mudiii-turn.mjs +82 -1
  26. package/src/services/mudiii-viz.mjs +17 -7
  27. package/src/services/p2p-room.mjs +130 -9
  28. package/src/services/predator-prey.mjs +524 -69
  29. package/src/services/spider-fly-turn.mjs +128 -56
  30. package/src/services/spider-fly-viz.mjs +65 -71
  31. package/src/services/world-teach.mjs +3 -3
  32. package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
  33. package/src/surfaces/web/memory-ask-browser.bundle.js +118 -118
  34. package/src/surfaces/web/mud-browser-entry.mjs +10 -3
  35. package/src/surfaces/web/mudiii-browser-entry.mjs +3 -3
  36. package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
  37. package/src/services/spider-fly.mjs +0 -943
@@ -78,10 +78,18 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
78
78
  preyVisionRadius: 3,
79
79
  preySpawnIntervalTurns: 5,
80
80
  foodSpawnIntervalTurns: 3,
81
- spawnedFoodMass: 1,
81
+ // Spawned and placed food are one thing: the same hay bale, worth the
82
+ // same. The two knobs stay separate so a world can still tell them apart.
83
+ spawnedFoodMass: 2,
82
84
  placedFoodMass: 2,
83
85
  maxPreyPopulation: 6,
84
86
  maxFoodItems: 8,
87
+ // Whether food only enters an agent's belief once it is within vision
88
+ // radius, the same as every other candidate. True by default — a crumb
89
+ // known to everyone the instant it spawns changes the foraging feel, and
90
+ // this is the one switch that turns that off (visionRadius: Infinity for
91
+ // the food-only belief call) without any new belief machinery.
92
+ foodVisionGated: true,
85
93
  }),
86
94
  guessNumber: Object.freeze({
87
95
  defaultLo: 1,
@@ -152,6 +160,7 @@ const MUDIII_KEY_MAP = Object.freeze({
152
160
  placed_food_mass: "placedFoodMass",
153
161
  max_prey_population: "maxPreyPopulation",
154
162
  max_food_items: "maxFoodItems",
163
+ food_vision_gated: "foodVisionGated",
155
164
  });
156
165
 
157
166
  const ADVENTURE_KEY_MAP = Object.freeze({
@@ -123,6 +123,10 @@ export function correctMisspellings(text) {
123
123
  return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
124
124
  }
125
125
 
126
+ /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
127
+ * ("what is a cache", "is a module a component"), never a teach declarative. */
128
+ export const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
129
+
126
130
  const FILLER_RE = FILLER_WORDS.length
127
131
  ? new RegExp(
128
132
  "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b\\s*,?",
@@ -0,0 +1,232 @@
1
+ // memory/retraction.mjs — making a retraction survive the next sync.
2
+ //
3
+ // removeFacts is a real delete, and deleting from a replicated grow-only set is
4
+ // not a G-Set operation: a peer that still holds the fact re-sends it and the
5
+ // retraction is undone. What closes that hole is the move compaction's chain
6
+ // summary already makes. The retraction leaves a RECORD behind, the record
7
+ // carries the record ids it suppressed, and two records at one id merge by
8
+ // UNION of those ids. Union is a join, so two peers that retracted at different
9
+ // moments converge, and merging twice changes nothing.
10
+ //
11
+ // A retraction suppresses ONE SOURCE'S assertion, not the whole triple group.
12
+ // Two peers who independently taught the same fact hold two records; one of
13
+ // them retracting leaves the fact standing and cited to the other, and the
14
+ // retraction stays on record rather than erasing what was asserted. That falls
15
+ // out of absorbing concrete record ids: a record another peer holds and this
16
+ // one never did is not in the set.
17
+ //
18
+ // The instant matters as much as the ids. One source asserting the same triple
19
+ // again lands on the SAME content address, so an id on its own cannot tell a
20
+ // suppressed assertion from a later, deliberate one. The record therefore
21
+ // carries the moment of the retraction, and both enforcement points compare an
22
+ // assertion's own time against it: at or before, suppressed; after, it stands.
23
+ // Max is a join too, so that field merges in either order and agrees.
24
+ //
25
+ // Pure: this module plans, merges and encodes retraction RECORDS. core.mjs owns
26
+ // the store, p2p-room.mjs owns the wire. Its own class, its own id suffix and
27
+ // its own predicate keep it clear of compaction — a compacted record and a
28
+ // retracted one mean opposite things, and one namespace would let a summary
29
+ // read as a tombstone. The CRDT vocabulary above is pinned in
30
+ // docs/references/papers/crdt.md.
31
+
32
+ export const RETRACTION_CLASS = "Retraction";
33
+
34
+ /** The predicate a retraction travels under. The sync filters admit it on its
35
+ * own, because a retraction has to cross a wire that a chat room gates on
36
+ * provenance kind and a mud room gates on world predicates. */
37
+ export const RETRACTION_PREDICATE = "mgx:retracted";
38
+
39
+ export const RETRACTED_RECORD_IDS_PROP = "mgx:retractedRecordIds";
40
+ export const RETRACTED_AT_PROP = "mgx:retractedAt";
41
+ export const RETRACTED_COUNT_PROP = "mgx:retractedCount";
42
+
43
+ const RETRACTION_SUFFIX = "#retracted";
44
+ const PROVENANCE_PROP = "mgx:factProvenance";
45
+ const NO_INSTANT = "-";
46
+
47
+ /** One retraction record per (triple, source), the same per-source shape
48
+ * compaction's chain summary keys on. */
49
+ export const retractionIdFor = (groupId, sourceId) => `${groupId}@${sourceId}${RETRACTION_SUFFIX}`;
50
+ export const isRetractionId = (id) => String(id || "").endsWith(RETRACTION_SUFFIX);
51
+
52
+ /** The record id a retraction suppresses, read back off its own id. */
53
+ export function retractionScopeOf(retractionId) {
54
+ const id = String(retractionId || "");
55
+ return isRetractionId(id) ? id.slice(0, -RETRACTION_SUFFIX.length) : "";
56
+ }
57
+
58
+ const attrValue = (ind, prop) => (ind?.attributes || []).find((a) => a?.prop === prop)?.value || "";
59
+ const idList = (value) => String(value || "").split(" ").filter(Boolean);
60
+ const tagList = (value) => String(value || "").split(" | ").filter(Boolean);
61
+
62
+ export const retractedRecordIds = (ind) => idList(attrValue(ind, RETRACTED_RECORD_IDS_PROP));
63
+ export const retractedAtOf = (ind) => attrValue(ind, RETRACTED_AT_PROP);
64
+
65
+ /** The later of two instants, tolerating "" and unparseable input on either
66
+ * side. Max is a join, which is what lets two records merge in either order. */
67
+ function laterOf(a, b) {
68
+ const at = Date.parse(a);
69
+ const bt = Date.parse(b);
70
+ if (!Number.isFinite(at)) return Number.isFinite(bt) ? String(b) : "";
71
+ if (!Number.isFinite(bt)) return String(a);
72
+ return bt > at ? String(b) : String(a);
73
+ }
74
+
75
+ const templateOf = (record) => ({
76
+ label: record?.label || "",
77
+ subject: attrValue(record, "rdf:subject"),
78
+ predicate: attrValue(record, "rdf:predicate"),
79
+ object: attrValue(record, "rdf:object"),
80
+ });
81
+
82
+ /**
83
+ * Build a retraction record from the only things that define one: which record
84
+ * ids it suppressed, when, and who said so. Planning and merging both come
85
+ * through here, so a record built by retracting and the same record reached by
86
+ * merging two halves come out identical — the property the whole design rests
87
+ * on. The triple it names is carried for a reader; nothing enforces on it.
88
+ */
89
+ function buildRetraction({ id, sourceId, template, ids, retractedAt, tags }) {
90
+ const sorted = [...new Set(ids)].filter(Boolean).sort();
91
+ const provenance = [...new Set(tags)].filter(Boolean).sort();
92
+ return {
93
+ id,
94
+ label: template?.label || "",
95
+ class: RETRACTION_CLASS,
96
+ derived_from: [],
97
+ mentions: [],
98
+ attributes: [
99
+ { prop: "rdf:subject", key: "subject", value: template?.subject || "" },
100
+ { prop: "rdf:predicate", key: "predicate", value: template?.predicate || "" },
101
+ { prop: "rdf:object", key: "object", value: template?.object || "" },
102
+ { prop: "mgx:sourceId", key: "sourceId", value: sourceId || "" },
103
+ { prop: RETRACTED_RECORD_IDS_PROP, key: "retractedRecordIds", value: sorted.join(" ") },
104
+ { prop: RETRACTED_COUNT_PROP, key: "retractedCount", value: String(sorted.length) },
105
+ { prop: RETRACTED_AT_PROP, key: "retractedAt", value: retractedAt || "" },
106
+ ...(provenance.length ? [{ prop: PROVENANCE_PROP, key: "provenance", value: provenance.join(" | ") }] : []),
107
+ ],
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Plan the record one source's retraction leaves behind. `recordIds` are the
113
+ * ids actually removed for that source — its head, its demoted leaves, and any
114
+ * summary standing for them. `existing` is the record this source already has
115
+ * here, if the triple has been retracted before; its ids come along, so
116
+ * retracting twice keeps one record rather than growing a chain of them.
117
+ *
118
+ * Null when there is nothing to suppress, which keeps a no-op removal from
119
+ * writing a tombstone for a fact nobody ever asserted.
120
+ */
121
+ export function planRetraction({ groupId, sourceId, recordIds = [], retractedAt = "", existing = null, template = null, provenance = "" }) {
122
+ if (!groupId || !sourceId) return null;
123
+ const ids = [...retractedRecordIds(existing), ...recordIds].filter(Boolean);
124
+ if (!ids.length) return null;
125
+ return buildRetraction({
126
+ id: retractionIdFor(groupId, sourceId),
127
+ sourceId,
128
+ template: template || templateOf(existing),
129
+ ids,
130
+ retractedAt: laterOf(retractedAtOf(existing), retractedAt),
131
+ tags: [...tagList(attrValue(existing, PROVENANCE_PROP)), ...tagList(provenance)],
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Join two retraction records that share an id: union the suppressed ids and
137
+ * the tags, take the later instant, then re-derive everything else from that.
138
+ * Commutative, associative and idempotent, because union and max both are and
139
+ * because the count is a function of the union rather than a separate running
140
+ * total.
141
+ */
142
+ export function mergeRetractions(existing, incoming) {
143
+ const template = templateOf(attrValue(existing, "rdf:subject") ? existing : incoming);
144
+ return buildRetraction({
145
+ id: existing?.id || incoming?.id,
146
+ sourceId: attrValue(existing, "mgx:sourceId") || attrValue(incoming, "mgx:sourceId"),
147
+ template,
148
+ ids: [...retractedRecordIds(existing), ...retractedRecordIds(incoming)],
149
+ retractedAt: laterOf(retractedAtOf(existing), retractedAtOf(incoming)),
150
+ tags: [...tagList(attrValue(existing, PROVENANCE_PROP)), ...tagList(attrValue(incoming, PROVENANCE_PROP))],
151
+ });
152
+ }
153
+
154
+ /** Whether an assertion made at `assertedAt` is old enough for the retraction
155
+ * to bite. An assertion with no readable time cannot show it is newer, so it
156
+ * is suppressed; the same goes for a record whose own instant will not parse.
157
+ * Erring this way keeps a resurrected copy out, and a source that means to say
158
+ * the thing again says it with a fresh tag. */
159
+ function notLaterThan(assertedAt, retractedAt) {
160
+ const asserted = Date.parse(assertedAt);
161
+ const retracted = Date.parse(retractedAt);
162
+ if (!Number.isFinite(retracted) || !Number.isFinite(asserted)) return true;
163
+ return asserted <= retracted;
164
+ }
165
+
166
+ /** Does this record suppress `recordId` as asserted at `assertedAt`? */
167
+ export function suppressesRecord(retraction, recordId, assertedAt = "") {
168
+ if (!retraction || !recordId) return false;
169
+ if (!retractedRecordIds(retraction).includes(recordId)) return false;
170
+ return notLaterThan(assertedAt, retractedAtOf(retraction));
171
+ }
172
+
173
+ /** The same check across a group's records. Both enforcement halves — the strip
174
+ * on read and the refusal on ingest — ask exactly this question. */
175
+ export function isRetractedRecord(retractions, recordId, assertedAt = "") {
176
+ for (const retraction of retractions || []) {
177
+ if (suppressesRecord(retraction, recordId, assertedAt)) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** The instant and the ids packed into one triple slot. The instant leads, ids
183
+ * follow, space separated — neither an ISO instant nor a record id carries a
184
+ * space, so the split is unambiguous. */
185
+ export function encodeRetractionValue(retractedAt, ids) {
186
+ const sorted = [...new Set(ids || [])].filter(Boolean).sort();
187
+ return [retractedAt || NO_INSTANT, ...sorted].join(" ");
188
+ }
189
+
190
+ export function decodeRetractionValue(value) {
191
+ const parts = String(value || "").split(" ").filter(Boolean);
192
+ const head = parts[0] || "";
193
+ const retractedAt = head === NO_INSTANT || !Number.isFinite(Date.parse(head)) ? "" : head;
194
+ const ids = retractedAt || head === NO_INSTANT ? parts.slice(1) : parts;
195
+ return { retractedAt, ids };
196
+ }
197
+
198
+ /** A stored record as the one wire fact that carries it: the record id it
199
+ * suppresses as the subject, the retraction predicate, and the instant plus
200
+ * the suppressed ids as the object. Everything else on the record is derived
201
+ * from those, so the round trip loses nothing a peer enforces on. */
202
+ export function retractionWireFact(record) {
203
+ const scope = retractionScopeOf(record?.id);
204
+ if (!scope) return null;
205
+ return {
206
+ id: record.id,
207
+ subject: scope,
208
+ predicate: RETRACTION_PREDICATE,
209
+ object: encodeRetractionValue(retractedAtOf(record), retractedRecordIds(record)),
210
+ provenance: attrValue(record, PROVENANCE_PROP),
211
+ };
212
+ }
213
+
214
+ /** The other direction: a received wire fact as the record it stands for.
215
+ * Null for anything that is not a well-formed retraction, so a malformed
216
+ * message is dropped rather than merged. */
217
+ export function retractionFromWire(fact) {
218
+ if (!fact || fact.predicate !== RETRACTION_PREDICATE) return null;
219
+ const scope = String(fact.subject || "");
220
+ const at = scope.indexOf("@");
221
+ if (at <= 0 || at === scope.length - 1) return null;
222
+ const { retractedAt, ids } = decodeRetractionValue(fact.object);
223
+ if (!ids.length) return null;
224
+ return buildRetraction({
225
+ id: `${scope}${RETRACTION_SUFFIX}`,
226
+ sourceId: scope.slice(at + 1),
227
+ template: null,
228
+ ids,
229
+ retractedAt,
230
+ tags: tagList(fact.provenance),
231
+ });
232
+ }
@@ -5,6 +5,13 @@
5
5
  // What actually needs syncing is the delta: whatever a person or a peer
6
6
  // actually added since boot.
7
7
  import { provenanceTagToSource } from "../memory/trust.mjs";
8
+ import { RETRACTION_PREDICATE } from "../memory/retraction.mjs";
9
+
10
+ // A retraction crosses on both surfaces, whatever else they disagree about.
11
+ // It carries no teach tag of its own to key on and no world predicate, so
12
+ // leaving it to either filter's own rule would strand it and the deleted fact
13
+ // would come straight back from the next peer that still holds it.
14
+ const alwaysSyncable = (row) => row?.predicate === RETRACTION_PREDICATE;
8
15
 
9
16
  // "teachNode" is the same human teaching, seen from one hop further out: a
10
17
  // peer's own relabeled tag, keyed on the node id it carries. It syncs for
@@ -16,6 +23,7 @@ const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator", "teachNode"]);
16
23
  * asserted — never a row from the shipped corpus. */
17
24
  export function chatSyncableFacts(rows) {
18
25
  return rows.filter((row) => {
26
+ if (alwaysSyncable(row)) return true;
19
27
  const source = provenanceTagToSource(row.provenance);
20
28
  return source ? CHAT_SYNCABLE_KINDS.has(source.kind) : false;
21
29
  });
@@ -31,5 +39,5 @@ export function chatSyncableFacts(rows) {
31
39
  * this module's own P2P predicates via `extraPredicates`. */
32
40
  export function mudSyncableFacts(rows, isMudStatePredicate, extraPredicates = []) {
33
41
  const extra = new Set(extraPredicates);
34
- return rows.filter((row) => extra.has(row.predicate) || isMudStatePredicate(row.predicate));
42
+ return rows.filter((row) => alwaysSyncable(row) || extra.has(row.predicate) || isMudStatePredicate(row.predicate));
35
43
  }
@@ -5,7 +5,7 @@
5
5
  // structural self-description facts below quote the same shipped defaults
6
6
  // the engine actually runs. Both
7
7
  // scripts/gen-spider-fly-world.mjs (writes corpus/worlds/src/spider-fly.jsonl)
8
- // and src/services/spider-fly.mjs (the runtime) read the SAME grid/web
8
+ // and src/services/spider-fly-turn.mjs (the runtime) read the SAME grid/web
9
9
  // constants from here, so the shipped world and the engine that plays it can
10
10
  // never drift apart.
11
11
 
@@ -14,28 +14,34 @@ import { DEFAULT_GAME_CONFIG } from "./game-config.mjs";
14
14
  export const WORLD_NAME = "spider-fly";
15
15
  export const GRID_SIZE = 10;
16
16
 
17
- // The spider's home cell, and the web's Chebyshev radius around it (radius 1
18
- // = a 3x3 block, PLAN_SPIDER_FLY.md §3). Corner-ish on purpose (§4): a spider
19
- // that starts here sees close to half the board just from edge-clipping.
17
+ // The spider's home cell, and the web's Chebyshev radius around it (radius 1 =
18
+ // a 3x3 block). Corner-ish on purpose: a spider that starts here sees close to
19
+ // half the board just from edge-clipping.
20
20
  export const WEB_HOME = Object.freeze({ x: 2, y: 2 });
21
21
  export const WEB_RADIUS = 1;
22
22
 
23
- // A spider-built dynamic web (src/services/spider-fly.mjs's hasActiveWebAt)
24
- // stays active for this many turns past the turn it was built, mirroring the
25
- // static home zone's own always-on web without needing separate code paths.
23
+ // A spider-built web stays active for this many turns past the turn it was
24
+ // spun, mirroring the home zone's own always-on web without a second code path.
26
25
  export const WEB_DURATION_TURNS = 10;
27
26
 
28
- // Spider mass mirrors a fly's own (src/services/spider-fly.mjs's
29
- // FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
30
- // does, and gains exactly a fly's remaining mass on an eat. Heavier starting
31
- // mass than a single fly's worth on purpose a spider that eats nothing for
32
- // a while has some runway before starving. The decrement is half a fly's own
33
- // (spiders live longer between meals than flies do), and — like every other
34
- // tunable here — overridable per session via tmct.toml's [games.spider-fly]
35
- // (src/domain/game-config.mjs).
27
+ // A spider starves like a fly does, and gains exactly a fly's remaining mass on
28
+ // an eat. Heavier starting mass than a single fly's worth on purpose — a spider
29
+ // that eats nothing for a while has some runway before starving. The decrement
30
+ // is half a fly's own (spiders live longer between meals than flies do), and —
31
+ // like every other tunable here overridable per session via tmct.toml's
32
+ // [games.spider-fly] (src/domain/game-config.mjs).
36
33
  export const SPIDER_INITIAL_MASS = 15;
37
34
  export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
38
35
 
36
+ /** The cast the shared predator/prey engine runs this board with. Same shape as
37
+ * the town square's own roles object, minus the food entry: nothing inert
38
+ * lies on a spider-and-fly board, and a null food role is what says so. */
39
+ export const SPIDER_FLY_ROLES = Object.freeze({
40
+ predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider" }),
41
+ prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly" }),
42
+ food: null,
43
+ });
44
+
39
45
  export const cellId = (x, y) => `cell-${x}-${y}`;
40
46
 
41
47
  const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
@@ -51,9 +57,9 @@ export const chebyshevDistance = (ax, ay, bx, by) => Math.max(Math.abs(ax - bx),
51
57
  export const inBounds = (x, y) => x >= 1 && x <= GRID_SIZE && y >= 1 && y <= GRID_SIZE;
52
58
 
53
59
  /** Every cell within Chebyshev `radius` of (cx, cy), clipped to the board, in
54
- * raster order — the one visibility primitive PLAN_SPIDER_FLY.md §4 names
55
- * ("visibleCells(cx, cy, radius=4)"), shared by both agents' belief and this
56
- * module's own web-block derivation below. */
60
+ * raster order — the one visibility primitive ("visibleCells(cx, cy, radius=4)")
61
+ * shared by both agents' belief and this module's own web-block derivation
62
+ * below. */
57
63
  export function visibleCells(cx, cy, radius) {
58
64
  const out = [];
59
65
  for (let y = Math.max(1, cy - radius); y <= Math.min(GRID_SIZE, cy + radius); y += 1) {
@@ -66,8 +72,7 @@ export function visibleCells(cx, cy, radius) {
66
72
 
67
73
  export const isInWebBlock = (x, y) => chebyshevDistance(x, y, WEB_HOME.x, WEB_HOME.y) <= WEB_RADIUS;
68
74
 
69
- /** Every board-edge (perimeter) cell, raster order — where a spawned fly
70
- * arrives (PLAN_SPIDER_FLY.md §10). */
75
+ /** Every board-edge (perimeter) cell, raster order. */
71
76
  export function perimeterCells() {
72
77
  const out = [];
73
78
  for (let y = 1; y <= GRID_SIZE; y += 1) {
@@ -94,11 +99,9 @@ export const DIRECTION_DELTA = Object.freeze({
94
99
  * sits EXACTLY one cardinal step away (DIRECTION_DELTA) — null for the same
95
100
  * cell, a diagonal, or any multi-step gap, so a caller never overstates
96
101
  * "adjacent". The one shared primitive both the engine's own plan-driven
97
- * facing (spider-fly.mjs) and the chat dock's deception pills
98
- * (spider-fly-turn.mjs's pillsForSpiderFly) need — defined once here so
99
- * neither has to re-derive it, and so the engine layer never has to import
100
- * the chat-turn layer to get it (spider-fly-turn.mjs already imports
101
- * spider-fly.mjs; the reverse would cycle). */
102
+ * facing and the chat dock's deception pills (spider-fly-turn.mjs's
103
+ * pillsForSpiderFly) need — defined once here so neither has to re-derive
104
+ * it. */
102
105
  export function oneStepDirectionBetween(fromCell, toCell) {
103
106
  for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
104
107
  if (fromCell.x + dx === toCell.x && fromCell.y + dy === toCell.y) return direction;
@@ -106,11 +109,10 @@ export function oneStepDirectionBetween(fromCell, toCell) {
106
109
  return null;
107
110
  }
108
111
 
109
- /** The world's seed taxonomy (PLAN_SPIDER_FLY.md §7): enough for the
110
- * ontology-to-sprite worked example (a poodle sprite, a sheepdog falling
111
- * back to the generic dog sprite) to run on the default persona, no
112
- * --persona-size large flag required. [subject, superclass] pairs, written
113
- * as rdfs:subClassOf facts. */
112
+ /** The world's seed taxonomy enough for the ontology-to-sprite worked example
113
+ * (a poodle sprite, a sheepdog falling back to the generic dog sprite) to run
114
+ * on the default persona without the --persona-size large flag. [subject,
115
+ * superclass] pairs, written as rdfs:subClassOf facts. */
114
116
  export const SEED_TAXONOMY = Object.freeze([
115
117
  Object.freeze(["poodle", "dog"]),
116
118
  Object.freeze(["sheepdog", "dog"]),
@@ -124,13 +126,56 @@ export const SEED_TAXONOMY = Object.freeze([
124
126
  export const WORLD_OPENING =
125
127
  "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.";
126
128
 
129
+ /** This board as the shared predator/prey engine's own layout: a bare 10x10
130
+ * grid, the always-on web block, and the cast a fresh session mints. `props`
131
+ * is empty and stays empty — nothing here blocks movement, so every cell is
132
+ * open and the whole perimeter takes an arrival. */
133
+ export const SPIDER_FLY_LAYOUT = Object.freeze({
134
+ name: WORLD_NAME,
135
+ gridSize: GRID_SIZE,
136
+ opening: WORLD_OPENING,
137
+ boardNoun: "board",
138
+ boardSubject: "board",
139
+ cast: Object.freeze({ predators: 1, prey: 1 }),
140
+ props: Object.freeze([]),
141
+ staticWebAt: isInWebBlock,
142
+ webHomeCell: cellId(WEB_HOME.x, WEB_HOME.y),
143
+ });
144
+
145
+ /** The spider-and-fly knobs restated in the engine's role-keyed shape, with the
146
+ * three mechanics this cast wants switched on. The public [games.spider-fly]
147
+ * table stays species-keyed on purpose: a tmct.toml key and a page slider keep
148
+ * the names a player of THIS game would use, and the translation lives here.
149
+ * Unset keys fall back to the shipped defaults, so a partial slider payload is
150
+ * always a complete engine config. Pure. */
151
+ export function spiderFlyEngineConfig(knobs) {
152
+ const k = { ...DEFAULT_GAME_CONFIG.spiderFly, ...(knobs || {}) };
153
+ return {
154
+ predatorInitialMass: k.spiderInitialMass,
155
+ predatorMassDecrementPerTurn: k.spiderMassDecrementPerTurn,
156
+ predatorVisionRadius: k.spiderVisionRadius,
157
+ preyInitialMass: k.flyInitialMass,
158
+ preyMassDecrementPerTurn: k.flyMassDecrementPerTurn,
159
+ preyVisionRadius: k.flyVisionRadius,
160
+ preySpawnIntervalTurns: k.flySpawnIntervalTurns,
161
+ webDurationTurns: k.webDurationTurns,
162
+ eggLayMassThreshold: k.eggLayMassThreshold,
163
+ eggHatchDelayTurns: k.eggHatchDelayTurns,
164
+ eggHatchCount: k.eggHatchCount,
165
+ minHatchlingMass: k.minHatchlingMass,
166
+ carryPreyToWeb: true,
167
+ buildWebs: true,
168
+ layEggs: true,
169
+ };
170
+ }
171
+
127
172
  /** Every fact row the shipped world source carries: cell typing, grid
128
173
  * adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
129
174
  * seed taxonomy — plain { world, kind:"fact", subject, predicate, object }
130
175
  * objects, the exact shape src/domain/worlds-pack.mjs's isWorldFactRow
131
176
  * reads. Deliberately NOT spider-1/fly-1: the board is reusable static
132
177
  * content, minted game entities are a fresh session's own state
133
- * (src/services/spider-fly.mjs's startSpiderFlyGame). */
178
+ * (src/services/spider-fly-turn.mjs's startSpiderFlyGame). */
134
179
  export function* worldFactRows() {
135
180
  for (let y = 1; y <= GRID_SIZE; y += 1) {
136
181
  for (let x = 1; x <= GRID_SIZE; x += 1) {
@@ -245,10 +290,10 @@ export function isLiveRenderableAgent(id, state) {
245
290
 
246
291
  /** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's
247
292
  * shared validator ("every world needs at least one rule row") passes.
248
- * src/services/spider-fly.mjs never reads these back: grid movement is
249
- * hand-written pathfinding over findActionPath/findReachableSet
250
- * (PLAN_SPIDER_FLY.md §5), not the taught action-Rule DSL, so this rides in
251
- * the shard unused, same as an unrelated fact would. */
293
+ * src/services/spider-fly-turn.mjs never reads these back: grid movement is
294
+ * hand-written pathfinding over findActionPath/findReachableSet, not the
295
+ * taught action-Rule DSL, so this rides in the shard unused, same as an
296
+ * unrelated fact would. */
252
297
  export function* worldRuleRows() {
253
298
  yield {
254
299
  world: WORLD_NAME, kind: "rule", name: "go", ruleKind: "action-signature",