@polycode-projects/the-mechanical-code-talker 5.0.4 → 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.
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +53 -0
- package/src/domain/game-config.mjs +6 -0
- package/src/domain/memory/causal-stability.mjs +82 -0
- package/src/domain/town-square-world.mjs +36 -0
- package/src/services/mudiii-turn.mjs +250 -1
- package/src/services/mudiii-viz.mjs +60 -45
- package/src/services/predator-prey.mjs +68 -7
- package/src/surfaces/web/memory-ask-browser.bundle.js +91 -91
- package/src/surfaces/web/mudiii-browser-entry.mjs +86 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "5.0.
|
|
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;
|
|
@@ -90,6 +90,11 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
|
|
|
90
90
|
// this is the one switch that turns that off (visionRadius: Infinity for
|
|
91
91
|
// the food-only belief call) without any new belief machinery.
|
|
92
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,
|
|
93
98
|
}),
|
|
94
99
|
guessNumber: Object.freeze({
|
|
95
100
|
defaultLo: 1,
|
|
@@ -161,6 +166,7 @@ const MUDIII_KEY_MAP = Object.freeze({
|
|
|
161
166
|
max_prey_population: "maxPreyPopulation",
|
|
162
167
|
max_food_items: "maxFoodItems",
|
|
163
168
|
food_vision_gated: "foodVisionGated",
|
|
169
|
+
teach: "teach",
|
|
164
170
|
});
|
|
165
171
|
|
|
166
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
|
+
}
|
|
@@ -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
|
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
TOWN_SQUARE_LAYOUTS, DEFAULT_GRID_SIZE, DIRECTION_DELTA,
|
|
17
|
-
cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
|
|
17
|
+
cellId, parseCellId, inBounds, isSolid, chebyshevDistance, oneStepDirectionBetween,
|
|
18
18
|
agentKindOf, liveIdsOfKind, layoutNamed, isFoodId,
|
|
19
19
|
} from "../domain/town-square-world.mjs";
|
|
20
20
|
import {
|
|
21
21
|
MUDIII_ROLES, foldTownSquareState, startTownSquareGame, runTownSquareTick,
|
|
22
22
|
placeFood, roleOfId, beliefSnapshotFor,
|
|
23
23
|
} from "./predator-prey.mjs";
|
|
24
|
+
import { snapshotSubject } from "./adventure.mjs";
|
|
25
|
+
import { correctMisspellings, QUESTION_LEAD_RE } from "../domain/interpret/normalize.mjs";
|
|
24
26
|
import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
|
|
25
27
|
import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
|
|
26
28
|
import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
|
|
@@ -553,6 +555,242 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
|
|
|
553
555
|
});
|
|
554
556
|
}
|
|
555
557
|
|
|
558
|
+
// ---- the teach lane: a declarative sentence read as a board fact -------------
|
|
559
|
+
//
|
|
560
|
+
// The town square's own half of the world-teach act world-teach.mjs performs
|
|
561
|
+
// for a manor and a burrow, and shaped the same way: a small closed sentence
|
|
562
|
+
// table, one additive planner over the live fold, and a "noted — … now."
|
|
563
|
+
// confirmation carrying the same `world:<name>:taught:turnK` provenance.
|
|
564
|
+
//
|
|
565
|
+
// It stays here rather than routing through world-teach.mjs because that
|
|
566
|
+
// module's gates are written for a ROOM world. It declines by naming rooms,
|
|
567
|
+
// mints a fresh portable when the subject is one the world has never heard
|
|
568
|
+
// of, and plans against foldWorldState. A board has cells instead of rooms
|
|
569
|
+
// and one fold of its own, and nothing here ever mints: every subject must
|
|
570
|
+
// already resolve to a live individual foldTownSquareState folds, or the
|
|
571
|
+
// write would put a fact on the board that the board cannot draw.
|
|
572
|
+
|
|
573
|
+
const PLACEMENT_PREDICATE = "mgx:currently-in";
|
|
574
|
+
const MASS_PREDICATE = "mgx:hasMass";
|
|
575
|
+
const MOOD_PREDICATE = "mgx:feels";
|
|
576
|
+
const FACING_PREDICATE = "mgx:facing";
|
|
577
|
+
const PLACED_BY_PREDICATE = "mgx:placed-by";
|
|
578
|
+
|
|
579
|
+
// The families foldTownSquareState ranks by (epoch, turn). A row in one of
|
|
580
|
+
// them needs a snapshot subject or it ranks as turn 0 and loses to anything
|
|
581
|
+
// already played about the same thing. mgx:placed-by is read raw and keeps
|
|
582
|
+
// its bare subject, exactly as placeFood writes it.
|
|
583
|
+
const TAUGHT_SNAPSHOT_PREDICATES = new Set([
|
|
584
|
+
PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
|
|
585
|
+
]);
|
|
586
|
+
|
|
587
|
+
// The closed cast vocabulary a taught sentence may name, matching the lane's
|
|
588
|
+
// own recognizers above. Mood and facing take an agent alone — a crumb has
|
|
589
|
+
// neither — while a cell and a weight are true of an inert item too.
|
|
590
|
+
const CAST_SUBJECT = "(fox|goblin|crumb|morsel)(?:-(\\d+))?";
|
|
591
|
+
const AGENT_SUBJECT = "(fox|goblin)(?:-(\\d+))?";
|
|
592
|
+
const ITEM_SUBJECT = "(crumb|morsel)(?:-(\\d+))?";
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The town square's sentence table: every fact about this board a person can
|
|
596
|
+
* state, one row per predicate the fold reads. Checked in order, first match
|
|
597
|
+
* wins.
|
|
598
|
+
*
|
|
599
|
+
* Fox-1 is at cell-3-4. mgx:currently-in
|
|
600
|
+
* The goblin weighs 4. mgx:hasMass
|
|
601
|
+
* The fox feels angry. mgx:feels
|
|
602
|
+
* Goblin-2 faces north. mgx:facing
|
|
603
|
+
* The baker put morsel-1 there. mgx:placed-by
|
|
604
|
+
*
|
|
605
|
+
* A bare kind ("the fox") names whichever individual of that kind is live and
|
|
606
|
+
* lowest-numbered; a numbered id names exactly one. Closed on both sides: a
|
|
607
|
+
* mood or a direction outside the engine's own words does not parse at all,
|
|
608
|
+
* so nothing here can write a value a renderer has no drawing for.
|
|
609
|
+
*/
|
|
610
|
+
const TOWN_SQUARE_TEACH_PATTERNS = [
|
|
611
|
+
{ kind: "placement", predicate: PLACEMENT_PREDICATE,
|
|
612
|
+
re: new RegExp(`^(?:the\\s+)?${CAST_SUBJECT}\\s+is\\s+at\\s+(cell-\\d+-\\d+)[.!\\s]*$`, "i") },
|
|
613
|
+
{ kind: "mass", predicate: MASS_PREDICATE,
|
|
614
|
+
re: new RegExp(`^(?:the\\s+)?${CAST_SUBJECT}\\s+weighs\\s+(\\d+(?:\\.\\d+)?)[.!\\s]*$`, "i") },
|
|
615
|
+
{ kind: "mood", predicate: MOOD_PREDICATE,
|
|
616
|
+
re: new RegExp(`^(?:the\\s+)?${AGENT_SUBJECT}\\s+feels\\s+(calm|angry|scared|happy)[.!\\s]*$`, "i") },
|
|
617
|
+
{ kind: "facing", predicate: FACING_PREDICATE,
|
|
618
|
+
re: new RegExp(`^(?:the\\s+)?${AGENT_SUBJECT}\\s+faces\\s+(north|south|east|west)[.!\\s]*$`, "i") },
|
|
619
|
+
];
|
|
620
|
+
|
|
621
|
+
// The one sentence whose subject is not its leading noun: the item is the
|
|
622
|
+
// subject and the placer is the object, which is the direction "who put that
|
|
623
|
+
// there?" reads the row back in.
|
|
624
|
+
const TOWN_SQUARE_PLACED_BY_RE = new RegExp(
|
|
625
|
+
`^(?:the\\s+)?([a-z][a-z-]*)\\s+(?:put|placed|dropped)\\s+(?:the\\s+)?${ITEM_SUBJECT}\\s+there[.!\\s]*$`,
|
|
626
|
+
"i",
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
/** One line -> `{ kind, predicate, kindWord, num, object }`, or null when the
|
|
630
|
+
* table recognizes nothing — an honest miss, never a guessed shape.
|
|
631
|
+
* `kindWord`/`num` name the individual the sentence is about; the caller
|
|
632
|
+
* resolves that pair against the live board. Pure. */
|
|
633
|
+
export function parseTownSquareTeachLine(line) {
|
|
634
|
+
const trimmed = String(line || "").trim();
|
|
635
|
+
if (!trimmed) return null;
|
|
636
|
+
for (const { kind, predicate, re } of TOWN_SQUARE_TEACH_PATTERNS) {
|
|
637
|
+
const m = trimmed.match(re);
|
|
638
|
+
if (!m) continue;
|
|
639
|
+
return { kind, predicate, kindWord: m[1].toLowerCase(), num: m[2] ?? null, object: m[3].toLowerCase() };
|
|
640
|
+
}
|
|
641
|
+
const placed = trimmed.match(TOWN_SQUARE_PLACED_BY_RE);
|
|
642
|
+
if (!placed) return null;
|
|
643
|
+
return {
|
|
644
|
+
kind: "placed-by", predicate: PLACED_BY_PREDICATE,
|
|
645
|
+
kindWord: placed[2].toLowerCase(), num: placed[3] ?? null, object: placed[1].toLowerCase(),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The rows one already-resolved taught triple implies against the board's
|
|
651
|
+
* current fold — the town square's counterpart to mud-editor.mjs's
|
|
652
|
+
* planTaughtMudTriple, and additive for the same reason: one sentence only
|
|
653
|
+
* ever says what it says, so nothing it leaves out is evidence of anything.
|
|
654
|
+
* Re-asserting a fact the board already holds appends nothing, and `reason`
|
|
655
|
+
* says which of the two happened.
|
|
656
|
+
*
|
|
657
|
+
* Takes the fold alone rather than the raw rows its burrow counterpart also
|
|
658
|
+
* needs: every family this table can say is one foldTownSquareState folds, so
|
|
659
|
+
* there is no raw-row family left to diff against. Pure.
|
|
660
|
+
*/
|
|
661
|
+
export function planTaughtTownSquareTriple(state, triple) {
|
|
662
|
+
if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
|
|
663
|
+
const { subject, object } = triple;
|
|
664
|
+
switch (triple.kind) {
|
|
665
|
+
case "placement": {
|
|
666
|
+
const current = state?.placements?.get(subject);
|
|
667
|
+
if (current?.cell === object) return { toAppend: [], reason: `${subject} already stands at ${object}` };
|
|
668
|
+
return {
|
|
669
|
+
toAppend: [triple],
|
|
670
|
+
reason: current ? `${subject} moves from ${current.cell} to ${object}` : `${subject} is placed at ${object}`,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
case "mass": {
|
|
674
|
+
const current = state?.mass?.get(subject);
|
|
675
|
+
if (current && Number(current.value) === Number(object)) return { toAppend: [], reason: `${subject} already weighs ${object}` };
|
|
676
|
+
return { toAppend: [triple], reason: `${subject} weighs ${object}` };
|
|
677
|
+
}
|
|
678
|
+
case "mood": {
|
|
679
|
+
const current = state?.mood?.get(subject);
|
|
680
|
+
if (current?.value === object) return { toAppend: [], reason: `${subject} already feels ${object}` };
|
|
681
|
+
return { toAppend: [triple], reason: `${subject} feels ${object}` };
|
|
682
|
+
}
|
|
683
|
+
case "facing": {
|
|
684
|
+
const current = state?.facing?.get(subject);
|
|
685
|
+
if (current?.value === object) return { toAppend: [], reason: `${subject} already faces ${object}` };
|
|
686
|
+
return { toAppend: [triple], reason: `${subject} faces ${object}` };
|
|
687
|
+
}
|
|
688
|
+
case "placed-by": {
|
|
689
|
+
const current = state?.placedBy?.get(subject);
|
|
690
|
+
if (current?.by === object) return { toAppend: [], reason: `${object} already put ${subject} there` };
|
|
691
|
+
return { toAppend: [triple], reason: `${object} put ${subject} there` };
|
|
692
|
+
}
|
|
693
|
+
default:
|
|
694
|
+
return { toAppend: [], reason: `the board folds nothing for ${triple.predicate}` };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** One taught triple as the sentence the board says back, in world-teach.mjs's
|
|
699
|
+
* own `noted — … now.` shape. Pure. */
|
|
700
|
+
export function townSquareTeachConfirmation(triple) {
|
|
701
|
+
switch (triple.kind) {
|
|
702
|
+
case "placement": return `noted — ${triple.subject} is at ${triple.object} now.`;
|
|
703
|
+
case "mass": return `noted — ${triple.subject} weighs ${triple.object} now.`;
|
|
704
|
+
case "mood": return `noted — ${triple.subject} feels ${triple.object} now.`;
|
|
705
|
+
case "facing": return `noted — ${triple.subject} faces ${triple.object} now.`;
|
|
706
|
+
case "placed-by": return `noted — the ${triple.object} put ${triple.subject} there now.`;
|
|
707
|
+
default: return "noted — the board says that now.";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const teachDecline = (text, note) => ({ text, lane: "game-answer", note: `MUDIII — world-teach: ${note}`, miss: true });
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* One line read as a fact about the LIVE board, or null when it is not a
|
|
715
|
+
* teach sentence at all and the ordinary lane should have it. Writes the
|
|
716
|
+
* fold-versioned families under a snapshot subject stamped at the next tick's
|
|
717
|
+
* own turn number, the same convention placeFood uses so a teach and the tick
|
|
718
|
+
* that resolves it share one turn rather than the teach quietly spending one.
|
|
719
|
+
*
|
|
720
|
+
* Nobody moves in response: a taught fact never runs the ecology pass, which
|
|
721
|
+
* is the same trade world-teach.mjs makes for a manor.
|
|
722
|
+
*/
|
|
723
|
+
async function mudiiiTeachTurn(line, { memoryDir, cache, world, layout }) {
|
|
724
|
+
const trimmed = String(line || "").trim();
|
|
725
|
+
if (!trimmed || !memoryDir) return null;
|
|
726
|
+
// A trailing "?" is an unambiguous question, and a leading interrogative is
|
|
727
|
+
// the same signal one word earlier — a question must never reach a write
|
|
728
|
+
// boundary. Both mirror world-teach.mjs, which stands down on either.
|
|
729
|
+
if (/\?\s*$/.test(trimmed)) return null;
|
|
730
|
+
if (QUESTION_LEAD_RE.test(correctMisspellings(trimmed))) return null;
|
|
731
|
+
|
|
732
|
+
const parsed = parseTownSquareTeachLine(trimmed);
|
|
733
|
+
if (!parsed) return null;
|
|
734
|
+
|
|
735
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
736
|
+
const state = foldTownSquareState(rows);
|
|
737
|
+
const subject = resolveAgentId(parsed.kindWord, parsed.num, state);
|
|
738
|
+
if (!subject) {
|
|
739
|
+
return teachDecline(
|
|
740
|
+
`there's no live ${parsed.kindWord} on the board for that to be about.`,
|
|
741
|
+
`"${trimmed}" is about a ${parsed.kindWord} nothing live answers to; declined rather than minting one the board cannot draw`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (parsed.kind === "placement") {
|
|
746
|
+
const cell = parseCellId(parsed.object);
|
|
747
|
+
if (!cell || !inBounds(layout.gridSize, cell.x, cell.y)) {
|
|
748
|
+
return teachDecline(
|
|
749
|
+
`${parsed.object} is off the board — this square runs cell-1-1 to cell-${layout.gridSize}-${layout.gridSize}.`,
|
|
750
|
+
`"${trimmed}" names a cell outside the ${layout.gridSize}x${layout.gridSize} board`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (isSolid(layout, parsed.object)) {
|
|
754
|
+
return teachDecline(
|
|
755
|
+
`${parsed.object} is blocked — nothing stands inside a building.`,
|
|
756
|
+
`"${trimmed}" would stand ${subject} on a prop cell, which no path ever reaches`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const triple = { subject, predicate: parsed.predicate, object: parsed.object, kind: parsed.kind };
|
|
762
|
+
const { toAppend, reason } = planTaughtTownSquareTriple(state, triple);
|
|
763
|
+
if (!toAppend.length) {
|
|
764
|
+
return {
|
|
765
|
+
text: "the board already says that.",
|
|
766
|
+
lane: "game-answer",
|
|
767
|
+
miss: false,
|
|
768
|
+
note: `MUDIII — world-teach: "${trimmed}" asserts a fact the board already holds (${reason}); nothing written`,
|
|
769
|
+
taught: [],
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const k = state.tickCount + 1;
|
|
774
|
+
const epoch = state.epoch;
|
|
775
|
+
const facts = toAppend.map((t) => ({
|
|
776
|
+
subject: TAUGHT_SNAPSHOT_PREDICATES.has(t.predicate) ? snapshotSubject(t.subject, k, epoch) : t.subject,
|
|
777
|
+
predicate: t.predicate,
|
|
778
|
+
object: t.object,
|
|
779
|
+
}));
|
|
780
|
+
const provenance = `${worldProvenanceTag(world)}:taught:turn${k}`;
|
|
781
|
+
await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance })));
|
|
782
|
+
if (cache) cache.rows = null;
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
text: townSquareTeachConfirmation(triple),
|
|
786
|
+
lane: "game-answer",
|
|
787
|
+
miss: false,
|
|
788
|
+
goal: `change what the board says about ${subject}`,
|
|
789
|
+
note: `MUDIII — world-teach: ${reason}; wrote ${facts.length} row(s) at turn ${k} with provenance ${provenance}; no tick rides a taught fact`,
|
|
790
|
+
taught: facts,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
556
794
|
// ---- in-game orientation asides ---------------------------------------------
|
|
557
795
|
//
|
|
558
796
|
// "where is the fox", "where am I", "what can I do", "what is the fox's
|
|
@@ -708,6 +946,17 @@ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = nul
|
|
|
708
946
|
return runPlaceFoodTurn(putMatch[1], { memoryDir, gameConfig, world: mudiii.world, layout });
|
|
709
947
|
}
|
|
710
948
|
|
|
949
|
+
// The teach switch runs before the plan-frame guard for the same reason the
|
|
950
|
+
// food verb does: "the fox is at cell-3-4" reads as a planning frame on its
|
|
951
|
+
// leading noun, and answering a sentence this lane's own table accepts with
|
|
952
|
+
// "stop watching, then set your goal" refuses the one thing the switch is
|
|
953
|
+
// for. With the switch off nothing here runs and the lane behaves exactly
|
|
954
|
+
// as it always has.
|
|
955
|
+
if (gameConfig?.mudiii?.teach) {
|
|
956
|
+
const taught = await mudiiiTeachTurn(trimmed, { memoryDir, cache, world: mudiii.world, layout });
|
|
957
|
+
if (taught) return taught;
|
|
958
|
+
}
|
|
959
|
+
|
|
711
960
|
if (isPlanFrameLine(line)) {
|
|
712
961
|
return {
|
|
713
962
|
text: 'the town square game is running — say "stop watching" to end it, then set your goal.',
|