@polycode-projects/the-mechanical-code-talker 5.0.3 → 5.0.5

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.
@@ -50,17 +50,17 @@
50
50
  {
51
51
  "file": "src/town-square-chapel.jsonl",
52
52
  "bytes": 108408,
53
- "sha256": "2c378e51c2f7d53d700e557970b72035aadcbea9496cc959adee09dd77181334"
53
+ "sha256": "02036d284ec27244494e1f575b750efa71d9a0fc232e57dbd8b5dab7e6a6b7b3"
54
54
  },
55
55
  {
56
56
  "file": "src/town-square-market.jsonl",
57
57
  "bytes": 52311,
58
- "sha256": "91682ef54cdddd3d3fe0c5877d107f9577bff9d85621bfb4979947f89b029a45"
58
+ "sha256": "dd374f9cb1c6a13ccfab62badeb4150b8cfefdc349f834430c2c9e105c1d7a4f"
59
59
  },
60
60
  {
61
61
  "file": "src/town-square.jsonl",
62
62
  "bytes": 75251,
63
- "sha256": "3238dcd53827e4139c0752bebc204257cac4a4663a8263183d387cc98e739779"
63
+ "sha256": "2fb87416759d8cee1464b55d4e811b91e772d52f65e0f06a88eac1d3a8fc5e7f"
64
64
  }
65
65
  ],
66
66
  "files": [
@@ -106,18 +106,18 @@
106
106
  },
107
107
  {
108
108
  "file": "shards/town-square-chapel.jsonl.gz",
109
- "bytes": 5540,
110
- "sha256": "f7515fc12de8bf40f5a5c0635032be2d4fe2f5a18d153a1b64fdc2adbcf5745c"
109
+ "bytes": 5538,
110
+ "sha256": "826e8732b419b506098f4fbce1adcc07d11bd7d91ea672aa46f13ed3ccebb0bb"
111
111
  },
112
112
  {
113
113
  "file": "shards/town-square-market.jsonl.gz",
114
- "bytes": 3116,
115
- "sha256": "5791be29b86ad2329978a8bca76dafb9a0ae2419beb0d8d00b83b958adb989d6"
114
+ "bytes": 3113,
115
+ "sha256": "c6834f0754498ff800e7be620e3e2268c375686bd753fa9da9b7dd89a63aa009"
116
116
  },
117
117
  {
118
118
  "file": "shards/town-square.jsonl.gz",
119
- "bytes": 4277,
120
- "sha256": "657e3a8825e579e38b24b64f4df95c17ee95caa10862afc0067acdf1f2db842f"
119
+ "bytes": 4275,
120
+ "sha256": "460864ccee3a6eed9dae93f158abaf9780f2b7fb4051f45c3ebc63c7400b01d2"
121
121
  }
122
122
  ]
123
123
  }
@@ -920,7 +920,7 @@
920
920
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
921
921
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
922
922
  {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
923
- {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
923
+ {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
924
924
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
925
925
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
926
926
  {"world":"town-square-chapel","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -446,7 +446,7 @@
446
446
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
447
447
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
448
448
  {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
449
- {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
449
+ {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
450
450
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
451
451
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
452
452
  {"world":"town-square-market","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -678,7 +678,7 @@
678
678
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
679
679
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
680
680
  {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
681
- {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
681
+ {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
682
682
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
683
683
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
684
684
  {"world":"town-square","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -21,7 +21,7 @@
21
21
  "destPath": "public/models/creatures/fox.glb",
22
22
  "licence": "CC0-1.0",
23
23
  "author": "Quaternius",
24
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
24
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
25
25
  "bytes": 332748,
26
26
  "sha256": "2694028b47e84bad3b9d985aa7743dae02a3bcfddd6becd73b46c747a1e067b3",
27
27
  "targetHeight": 1.0,
@@ -30,7 +30,10 @@
30
30
  "walk": "Walk",
31
31
  "run": "Gallop",
32
32
  "attack": "Attack",
33
- "hit": ["Idle_HitReact_Left", "Idle_HitReact_Right"],
33
+ "hit": [
34
+ "Idle_HitReact_Left",
35
+ "Idle_HitReact_Right"
36
+ ],
34
37
  "death": "Death",
35
38
  "eat": "Eating"
36
39
  }
@@ -41,7 +44,7 @@
41
44
  "destPath": "public/models/creatures/goblin.glb",
42
45
  "licence": "CC0-1.0",
43
46
  "author": "Quaternius",
44
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
47
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
45
48
  "bytes": 47924,
46
49
  "sha256": "2f02ce927a8871554f3931655d75f1abb8237d36be8a19a35b09920d0812bc3c",
47
50
  "targetHeight": 2.1,
@@ -50,7 +53,9 @@
50
53
  "walk": "Walk",
51
54
  "run": "Run",
52
55
  "attack": "Attack",
53
- "hit": ["HitRecieve"],
56
+ "hit": [
57
+ "HitRecieve"
58
+ ],
54
59
  "death": "Death"
55
60
  }
56
61
  },
@@ -207,7 +212,7 @@
207
212
  "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
208
213
  "bytes": 14004,
209
214
  "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
210
- "targetHeight": 0.16,
215
+ "targetHeight": 0.6,
211
216
  "clips": null
212
217
  },
213
218
  {
@@ -219,7 +224,7 @@
219
224
  "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
220
225
  "bytes": 14004,
221
226
  "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
222
- "targetHeight": 0.36,
227
+ "targetHeight": 0.6,
223
228
  "clips": null
224
229
  }
225
230
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.3",
3
+ "version": "5.0.5",
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; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -50,6 +50,7 @@ import {
50
50
  planRetraction, mergeRetractions, retractionFromWire, retractionWireFact,
51
51
  isRetractedRecord, RETRACTION_CLASS,
52
52
  } from "../../domain/memory/retraction.mjs";
53
+ import { admittedNodes, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
53
54
  import { assertIndividualValid } from "./shacl.mjs";
54
55
 
55
56
  // The rollup vocabulary and its tuning constants live with the compaction
@@ -70,6 +71,8 @@ export {
70
71
  retractedRecordIds, retractedAtOf, retractionWireFact, retractionFromWire,
71
72
  } from "../../domain/memory/retraction.mjs";
72
73
 
74
+ export { admittedNodes, peersToConvince, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
75
+
73
76
  export const MEMORY_DIR_REL = join(".tmct", "memory");
74
77
  export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
75
78
 
@@ -3189,6 +3192,56 @@ export async function appendRetractions(dir, wireFacts) {
3189
3192
  return { merged: incoming.length, removed };
3190
3193
  }
3191
3194
 
3195
+ /** What this store could retire, and the roster it has to convince first.
3196
+ *
3197
+ * `roster` is the world's admission graph, folded to a set of node ids —
3198
+ * replicated, grow-only, and the same on every peer holding the same facts.
3199
+ * `retirable` is the tombstones every peer on that roster is known to hold.
3200
+ * `acknowledgedBy(nodeId)` is what supplies that evidence; nothing produces it
3201
+ * yet, so `retirable` reads empty and this is a report rather than a sweep.
3202
+ * Retiring nothing is the current behaviour, and it is the safe one: a
3203
+ * tombstone dropped one peer early lets that peer's copy resurrect a retracted
3204
+ * fact. See docs/references/papers/crdt.md. */
3205
+ export function retirableRetractions(memory, { self = "", acknowledgedBy = null } = {}) {
3206
+ const roster = admittedNodes(readFactRows(memory));
3207
+ const recordIds = [];
3208
+ for (const ind of memory?.individuals || []) {
3209
+ if (ind?.class === RETRACTION_CLASS && ind.id) recordIds.push(ind.id);
3210
+ }
3211
+ return { roster, retirable: stableRecordIds({ recordIds, roster, self, acknowledgedBy }) };
3212
+ }
3213
+
3214
+ /** Drop named retraction records. Takes the ids rather than choosing them, so a
3215
+ * caller has to have run the stability rule and passed its answer; ids that are
3216
+ * not retraction records are skipped. Returns the ids that went. */
3217
+ export async function retireRetractions(dir, ids) {
3218
+ const asked = new Set((ids || []).filter(Boolean));
3219
+ const retired = [];
3220
+ if (!asked.size) return { retired };
3221
+ await mutateMemory(dir, (payload) => {
3222
+ const drop = new Set();
3223
+ payload.individuals = (payload.individuals || []).filter((ind) => {
3224
+ if (ind?.class !== RETRACTION_CLASS || !asked.has(ind.id)) return true;
3225
+ drop.add(ind.id);
3226
+ return false;
3227
+ });
3228
+ if (!drop.size) return;
3229
+ for (const id of drop) retired.push(id);
3230
+ const idx = memoryIndexOf(payload);
3231
+ if (idx) {
3232
+ for (const id of drop) {
3233
+ idx.individualsById.delete(id);
3234
+ const groupId = factGroupId(id);
3235
+ const held = (idx.retractionsByGroup.get(groupId) || []).filter((r) => !drop.has(r.id));
3236
+ if (held.length) idx.retractionsByGroup.set(groupId, held);
3237
+ else idx.retractionsByGroup.delete(groupId);
3238
+ }
3239
+ }
3240
+ recountClasses(payload);
3241
+ });
3242
+ return { retired };
3243
+ }
3244
+
3192
3245
  /** The trust floor a fact must clear before a differing object counts as a real
3193
3246
  * contradiction (below it the fact is too weak to contradict anything). */
3194
3247
  const CONTRADICTION_TRUST_FLOOR = 0.5;
@@ -78,7 +78,9 @@ 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,
@@ -88,6 +90,11 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
88
90
  // this is the one switch that turns that off (visionRadius: Infinity for
89
91
  // the food-only belief call) without any new belief machinery.
90
92
  foodVisionGated: true,
93
+ // Whether the town-square lane accepts teaching. Its own knob rather than
94
+ // a share of the adventure one below: the board's sentence table is a
95
+ // different vocabulary, and a page checkbox on the town square should not
96
+ // have to set a key named after another surface.
97
+ teach: false,
91
98
  }),
92
99
  guessNumber: Object.freeze({
93
100
  defaultLo: 1,
@@ -159,6 +166,7 @@ const MUDIII_KEY_MAP = Object.freeze({
159
166
  max_prey_population: "maxPreyPopulation",
160
167
  max_food_items: "maxFoodItems",
161
168
  food_vision_gated: "foodVisionGated",
169
+ teach: "teach",
162
170
  });
163
171
 
164
172
  const ADVENTURE_KEY_MAP = Object.freeze({
@@ -0,0 +1,82 @@
1
+ // memory/causal-stability.mjs — deciding when a replicated tombstone has been
2
+ // held by enough peers to retire.
3
+ //
4
+ // A retraction record and a compaction summary both work by staying put: they
5
+ // carry the ids they suppressed, and any peer that re-delivers one of those ids
6
+ // gets refused. That is what makes a delete survive a sync over a grow-only
7
+ // set. It also means the records accumulate, because nothing yet says when one
8
+ // has done its job.
9
+ //
10
+ // The literature calls the missing rule CAUSAL STABILITY: a record is safe to
11
+ // drop once every replica that could still send a conflicting copy has it. Two
12
+ // inputs, and the mesh has one of them.
13
+ //
14
+ // - The ROSTER. `node:<joiner> mgx:invitedBy node:<inviter>` is an ordinary
15
+ // replicated fact, so the set of node ids ever admitted to a world is a
16
+ // grow-only union every peer computes the same way. `admittedNodes` reads
17
+ // it. Grow-only is exactly right here: a roster that could shrink would let
18
+ // a forgotten node's stale copy back in.
19
+ // - The ACKNOWLEDGEMENT. Nothing yet records that a named node holds a named
20
+ // record. `stableRecordIds` takes it as an argument rather than inventing
21
+ // it, and answers "nothing is stable" when it is absent — which is the
22
+ // current answer, and the safe one.
23
+ //
24
+ // Every rule here errs the same way. Dropping a tombstone one peer short lets
25
+ // that peer's copy resurrect a retracted fact, and that failure is silent,
26
+ // late, and reads as the memory inventing something. Retiring nothing is
27
+ // merely unbounded. So: an empty roster retires nothing, a member with no
28
+ // acknowledgement retires nothing, and an unparseable input retires nothing.
29
+ //
30
+ // Pure: no clock, no counter, no arrival order. The answer is a function of the
31
+ // fact set and the acknowledgement evidence handed in, which is the same
32
+ // invariant every read-time resolver over the store has to meet.
33
+ // docs/references/papers/crdt.md carries the full design and the options it
34
+ // rejected.
35
+ import { INVITED_BY_PREDICATE } from "../p2p/facts.mjs";
36
+
37
+ /** The node ids a world has ever admitted, from its admission edges. Both ends
38
+ * of each edge count: the joiner wrote the edge about itself, and it names the
39
+ * node that let it in. Sorted, so two peers holding the same facts hand the
40
+ * same roster to the rule below. */
41
+ export function admittedNodes(rows) {
42
+ const nodes = new Set();
43
+ for (const row of rows || []) {
44
+ if (row?.predicate !== INVITED_BY_PREDICATE) continue;
45
+ if (row.subject) nodes.add(String(row.subject));
46
+ if (row.object) nodes.add(String(row.object));
47
+ }
48
+ return [...nodes].sort();
49
+ }
50
+
51
+ /** The roster this node has to convince before retiring anything: every
52
+ * admitted node except itself. A node holding its own record proves nothing
53
+ * about who else still has a copy. */
54
+ export function peersToConvince(roster, self = "") {
55
+ const me = String(self || "");
56
+ return (roster || []).map(String).filter((id) => id && id !== me);
57
+ }
58
+
59
+ /**
60
+ * Which of `recordIds` every peer on the roster is known to hold.
61
+ *
62
+ * `acknowledgedBy(nodeId)` returns the record ids that node is known to hold.
63
+ * Nothing supplies it in the product yet, so it defaults to knowing nothing and
64
+ * the answer defaults to the empty set. That default is the gate: this rule
65
+ * cannot retire anything until something can show a peer holds a record.
66
+ *
67
+ * An empty roster answers with nothing too. A store with no admission edges has
68
+ * not shown it is alone; it has shown it does not know who else is out there,
69
+ * and a copy of it can be sitting in a closed browser tab.
70
+ */
71
+ export function stableRecordIds({ recordIds = [], roster = [], self = "", acknowledgedBy = null } = {}) {
72
+ const peers = peersToConvince(roster, self);
73
+ if (!peers.length) return [];
74
+ if (typeof acknowledgedBy !== "function") return [];
75
+ const held = new Map();
76
+ for (const peer of peers) {
77
+ const ids = acknowledgedBy(peer);
78
+ held.set(peer, new Set([...(ids || [])].map(String)));
79
+ }
80
+ const candidates = [...new Set((recordIds || []).map(String).filter(Boolean))].sort();
81
+ return candidates.filter((id) => peers.every((peer) => held.get(peer).has(id)));
82
+ }
@@ -57,9 +57,9 @@ export const chebyshevDistance = (ax, ay, bx, by) => Math.max(Math.abs(ax - bx),
57
57
  export const inBounds = (x, y) => x >= 1 && x <= GRID_SIZE && y >= 1 && y <= GRID_SIZE;
58
58
 
59
59
  /** Every cell within Chebyshev `radius` of (cx, cy), clipped to the board, in
60
- * raster order — the one visibility primitive PLAN_SPIDER_FLY.md §4 names
61
- * ("visibleCells(cx, cy, radius=4)"), shared by both agents' belief and this
62
- * 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. */
63
63
  export function visibleCells(cx, cy, radius) {
64
64
  const out = [];
65
65
  for (let y = Math.max(1, cy - radius); y <= Math.min(GRID_SIZE, cy + radius); y += 1) {
@@ -72,8 +72,7 @@ export function visibleCells(cx, cy, radius) {
72
72
 
73
73
  export const isInWebBlock = (x, y) => chebyshevDistance(x, y, WEB_HOME.x, WEB_HOME.y) <= WEB_RADIUS;
74
74
 
75
- /** Every board-edge (perimeter) cell, raster order — where a spawned fly
76
- * arrives (PLAN_SPIDER_FLY.md §10). */
75
+ /** Every board-edge (perimeter) cell, raster order. */
77
76
  export function perimeterCells() {
78
77
  const out = [];
79
78
  for (let y = 1; y <= GRID_SIZE; y += 1) {
@@ -110,11 +109,10 @@ export function oneStepDirectionBetween(fromCell, toCell) {
110
109
  return null;
111
110
  }
112
111
 
113
- /** The world's seed taxonomy (PLAN_SPIDER_FLY.md §7): enough for the
114
- * ontology-to-sprite worked example (a poodle sprite, a sheepdog falling
115
- * back to the generic dog sprite) to run on the default persona, no
116
- * --persona-size large flag required. [subject, superclass] pairs, written
117
- * 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. */
118
116
  export const SEED_TAXONOMY = Object.freeze([
119
117
  Object.freeze(["poodle", "dog"]),
120
118
  Object.freeze(["sheepdog", "dog"]),
@@ -293,9 +291,9 @@ export function isLiveRenderableAgent(id, state) {
293
291
  /** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's
294
292
  * shared validator ("every world needs at least one rule row") passes.
295
293
  * src/services/spider-fly-turn.mjs never reads these back: grid movement is
296
- * hand-written pathfinding over findActionPath/findReachableSet
297
- * (PLAN_SPIDER_FLY.md §5), not the taught action-Rule DSL, so this rides in
298
- * the shard unused, same as an unrelated fact would. */
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. */
299
297
  export function* worldRuleRows() {
300
298
  yield {
301
299
  world: WORLD_NAME, kind: "rule", name: "go", ruleKind: "action-signature",
@@ -82,6 +82,42 @@ export function oneStepDirectionBetween(fromCell, toCell) {
82
82
  return null;
83
83
  }
84
84
 
85
+ /** The eight points a facing may take, clockwise from north. The four
86
+ * cardinals are DIRECTION_DELTA's own keys, and they are the only ones a STEP
87
+ * can use — the grid has no diagonal exits. The four intercardinals are
88
+ * turn-only, which is what a forty-five degree turn on the spot writes. */
89
+ export const COMPASS_POINTS = Object.freeze([
90
+ "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest",
91
+ ]);
92
+
93
+ /** `facing` turned `degrees` clockwise (a negative angle turns the other way),
94
+ * landing on one of COMPASS_POINTS. Null when `facing` isn't a compass point
95
+ * or the angle isn't a whole multiple of 45, so a caller refuses rather than
96
+ * rounding a nonsense turn into a real one. Pure. */
97
+ export function turnedFacing(facing, degrees) {
98
+ const at = COMPASS_POINTS.indexOf(String(facing ?? ""));
99
+ if (at < 0) return null;
100
+ if (!Number.isInteger(degrees) || degrees % 45 !== 0) return null;
101
+ const steps = degrees / 45;
102
+ return COMPASS_POINTS[(((at + steps) % 8) + 8) % 8];
103
+ }
104
+
105
+ /** The compass point directly behind `facing`, or null. Pure. */
106
+ export function reverseFacing(facing) {
107
+ return turnedFacing(facing, 180);
108
+ }
109
+
110
+ /** The cell one step `direction` from `cell`, or null when `direction` names
111
+ * no cardinal step or `cell` doesn't parse. Bounds and props are NOT checked
112
+ * here: whether that cell can actually be entered is the exit table's answer,
113
+ * and there is only one of those. Pure. */
114
+ export function stepCellFrom(cell, direction) {
115
+ const at = parseCellId(cell);
116
+ const delta = DIRECTION_DELTA[String(direction ?? "")];
117
+ if (!at || !delta) return null;
118
+ return cellId(at.x + delta.dx, at.y + delta.dy);
119
+ }
120
+
85
121
  // ---- the prop vocabulary ------------------------------------------------------
86
122
 
87
123
  /** The closed set of noun stems a prop id may use ("house-1" -> "house"). A