@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1
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/README.md +176 -8
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +8 -8
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/sprites/src/sprite-facts.jsonl +52 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +53 -13
- package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
- package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
- package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
- package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
- package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
- package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
- package/corpus/worlds/src/mud-garden.jsonl +34 -1
- package/corpus/worlds/src/mud-hollow.jsonl +82 -0
- package/corpus/worlds/src/mud-warren.jsonl +124 -0
- package/corpus/worlds/src/spider-fly.jsonl +1 -1
- package/data/sprites/animal-icon.toml +11 -5
- package/data/sprites/book-icon.toml +9 -5
- package/data/sprites/cabinet-icon.toml +10 -5
- package/data/sprites/cellar-icon.toml +16 -6
- package/data/sprites/container-icon.toml +7 -3
- package/data/sprites/desk-icon.toml +8 -5
- package/data/sprites/dog-icon.toml +8 -5
- package/data/sprites/dog-with-colour-icon.toml +13 -12
- package/data/sprites/drawing-room-icon.toml +14 -5
- package/data/sprites/egg-icon.toml +6 -3
- package/data/sprites/fly-icon.toml +10 -5
- package/data/sprites/furniture-icon.toml +5 -3
- package/data/sprites/garden-icon.toml +10 -3
- package/data/sprites/key-icon.toml +3 -1
- package/data/sprites/kitchen-icon.toml +15 -6
- package/data/sprites/lamp-icon.toml +9 -4
- package/data/sprites/letter-icon.toml +7 -4
- package/data/sprites/library-icon.toml +14 -3
- package/data/sprites/pan-icon.toml +7 -3
- package/data/sprites/person-icon.toml +6 -2
- package/data/sprites/poodle-icon.toml +1 -1
- package/data/sprites/portable-icon.toml +6 -4
- package/data/sprites/portrait-icon.toml +7 -4
- package/data/sprites/room-icon.toml +11 -2
- package/data/sprites/spider-icon.toml +9 -3
- package/data/sprites/study-icon.toml +10 -2
- package/package.json +5 -4
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +26 -5
- package/src/domain/grammar/ace.mjs +40 -4
- package/src/domain/grammar/lexicon-core.json +2 -1
- package/src/domain/grammar/lexicon.mjs +31 -0
- package/src/domain/memory/trust.mjs +6 -4
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/reference-pack.mjs +31 -7
- package/src/domain/spider-fly-world.mjs +1 -1
- package/src/domain/sprite-map.mjs +10 -2
- package/src/domain/sprite-templates.mjs +8 -6
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +309 -73
- package/src/services/adventure.mjs +1034 -330
- package/src/services/chat-page-viz.mjs +1097 -16
- package/src/services/chat.mjs +99 -18
- package/src/services/code-explorer-viz.mjs +52 -14
- package/src/services/extract-facts.mjs +4 -7
- package/src/services/ingest-viz.mjs +54 -26
- package/src/services/ledger-viz.mjs +105 -56
- package/src/services/memory-panel-viz.mjs +24 -0
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +264 -87
- package/src/services/mud-viz.mjs +2606 -384
- package/src/services/p2p-room.mjs +559 -0
- package/src/services/plan-viz.mjs +119 -61
- package/src/services/research-viz.mjs +151 -57
- package/src/services/spider-fly-turn.mjs +1 -1
- package/src/services/spider-fly-viz.mjs +67 -43
- package/src/services/sprite-catalog-viz.mjs +175 -51
- package/src/services/viz-theme.mjs +15 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
- package/src/surfaces/web/mud-browser-entry.mjs +226 -18
- package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
|
|
12
12
|
import { parseImperative, OBJECT_PRONOUNS } from "../domain/grammar/ace.mjs";
|
|
13
|
+
import { loadLexicon, withProperNames, classify } from "../domain/grammar/lexicon.mjs";
|
|
13
14
|
import { register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
|
|
14
15
|
import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
|
|
15
16
|
import { actionFamilies } from "../domain/router/taught.mjs";
|
|
@@ -185,11 +186,32 @@ const POSITION_PREDICATES = new Set(["mgx:on-top-of", "mgx:on-plane", "mgx:under
|
|
|
185
186
|
const OPEN_PREDICATE = "mgx:is-open";
|
|
186
187
|
const MASS_PREDICATE = "mgx:hasMass";
|
|
187
188
|
const KNOWS_ABOUT_PREDICATE = "mgx:knows-about";
|
|
189
|
+
// What a thing is called on screen, when that differs from its id. A dug
|
|
190
|
+
// object needs a distinct id per instance and a plain name to read by, and
|
|
191
|
+
// this predicate is the only place the two are allowed to differ.
|
|
192
|
+
const DISPLAY_NAME_PREDICATE = "mgx:display-name";
|
|
188
193
|
const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
194
|
+
// The dig mechanic's own wiring: which room a world measures distance from, the
|
|
195
|
+
// kinds a dug room turns up, the richer set a den holds, and who lives in one.
|
|
196
|
+
// All four are the world's answers to the dig verb's questions, never scenery.
|
|
197
|
+
const ORIGIN_PREDICATE = "mgx:is-origin";
|
|
198
|
+
const DIG_SPAWN_PREDICATE = "mgx:dig-spawns";
|
|
199
|
+
const DEN_SPAWN_PREDICATE = "mgx:den-spawns";
|
|
200
|
+
const DEN_RESIDENT_PREDICATE = "mgx:den-resident";
|
|
201
|
+
const DIG_REACH_PREDICATE = "mgx:dig-reach";
|
|
202
|
+
const DIG_SPAWN_MAX_PREDICATE = "mgx:dig-spawn-max";
|
|
203
|
+
const DEN_CHANCE_PREDICATE = "mgx:den-chance-in";
|
|
204
|
+
const DEN_RESIDENT_CHANCE_PREDICATE = "mgx:den-resident-chance-in";
|
|
205
|
+
const MASS_DRAIN_PREDICATE = "mgx:mass-drain-per-turn";
|
|
206
|
+
// Where a thing that has left the world is placed. The world has no other way
|
|
207
|
+
// to say "out of play", and no room can be called either of these, so the
|
|
208
|
+
// sentinel is the whole convention: the readers below skip it exactly as they
|
|
209
|
+
// skip a hiding place. Which sentinel a character sits at IS the reason it is
|
|
210
|
+
// out — eaten by a predator, or starved once its mass ran out — so a caller
|
|
211
|
+
// can say which without a second fact to read.
|
|
192
212
|
const CONSUMED_PLACE = "eaten";
|
|
213
|
+
const STARVED_PLACE = "starved";
|
|
214
|
+
const OUT_OF_PLAY_PLACES = new Set([CONSUMED_PLACE, STARVED_PLACE]);
|
|
193
215
|
|
|
194
216
|
/** The rows a live world's STATE fold may see: those the world itself wrote
|
|
195
217
|
* (provenance empty, or `world:*` — the loaded shard and its @turn
|
|
@@ -205,6 +227,47 @@ export function worldActionRows(rows) {
|
|
|
205
227
|
});
|
|
206
228
|
}
|
|
207
229
|
|
|
230
|
+
// The predicates a played turn can write. Every one of them already exists
|
|
231
|
+
// above as a private constant; this set is the one place they are gathered so
|
|
232
|
+
// a caller outside this file can ask "is this fact part of the live world"
|
|
233
|
+
// without learning each name. rdf:type and the display name earn their place
|
|
234
|
+
// alongside the folded ones: a dug room and a dug object are minted mid-play,
|
|
235
|
+
// and a reader that never receives their class or their plain name cannot draw
|
|
236
|
+
// them at all.
|
|
237
|
+
const MUD_STATE_PREDICATES = new Set([
|
|
238
|
+
...PLACEMENT_PREDICATES,
|
|
239
|
+
...POSITION_PREDICATES,
|
|
240
|
+
OPEN_PREDICATE,
|
|
241
|
+
MASS_PREDICATE,
|
|
242
|
+
KNOWS_ABOUT_PREDICATE,
|
|
243
|
+
DISPLAY_NAME_PREDICATE,
|
|
244
|
+
"rdf:type",
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
/** Whether `predicate` carries live world state — where a thing stands, what
|
|
248
|
+
* it weighs, whether it is open, what somebody knows, which way a room leads,
|
|
249
|
+
* and the class and name a dug thing is minted with. The P2P sync filter
|
|
250
|
+
* reads this to tell a fact a turn produced from the page chrome around it.
|
|
251
|
+
* Pure. */
|
|
252
|
+
export function isMudStatePredicate(predicate) {
|
|
253
|
+
const p = String(predicate || "");
|
|
254
|
+
return MUD_STATE_PREDICATES.has(p) || EXIT_PREDICATE_RE.test(p);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every individual the world names — its rooms, its cast, its props, and
|
|
258
|
+
* anything dug up since — as the plain id strings a parser has to have
|
|
259
|
+
* DECLARED before it can resolve them. @turnN snapshots are skipped: a
|
|
260
|
+
* snapshot only ever repeats a subject its base row already named. Pure. */
|
|
261
|
+
export function worldIndividualNames(rows) {
|
|
262
|
+
const names = new Set();
|
|
263
|
+
for (const row of rows || []) {
|
|
264
|
+
if (SNAPSHOT_RE.test(row.subject)) continue;
|
|
265
|
+
if (row.predicate === "rdf:type" || PLACEMENT_PREDICATES.has(row.predicate)) names.add(row.subject);
|
|
266
|
+
if (EXIT_PREDICATE_RE.test(row.predicate)) { names.add(row.subject); names.add(row.object); }
|
|
267
|
+
}
|
|
268
|
+
return [...names].sort();
|
|
269
|
+
}
|
|
270
|
+
|
|
208
271
|
/** Fold fact rows into the CURRENT world state: per subject, the newest
|
|
209
272
|
* placement (base row = turn 0, @turnN snapshots override), the newest
|
|
210
273
|
* open/closed state, the newest mass, the exit map, and the turn counter (the
|
|
@@ -346,6 +409,59 @@ function backgroundOnlyMention(rows, state, object) {
|
|
|
346
409
|
return (rows || []).some((r) => r.subject === object || r.object === object);
|
|
347
410
|
}
|
|
348
411
|
|
|
412
|
+
/** True when `subject` is one of the world's cast rather than a prop: a
|
|
413
|
+
* declared person, or anything the world places with mgx:currently-in — the
|
|
414
|
+
* predicate every world reserves for a character standing in a room, props
|
|
415
|
+
* riding located-in/fixed-in/stands-locked-in instead. Both halves matter:
|
|
416
|
+
* ashcombe-hall types its staff `person`, while mud-garden types its animals
|
|
417
|
+
* `adventurer` and places them the same way, so a person-only test leaves a
|
|
418
|
+
* whole cast with nobody able to speak to it. */
|
|
419
|
+
function isCastMember(rows, state, subject) {
|
|
420
|
+
return isTyped(rows, subject, "person") || state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Who else is standing in `room` right now, sorted — the same currently-in
|
|
424
|
+
* placement the talk verb and the room affordances read, exposed so a caller
|
|
425
|
+
* rendering a room can name its cast without re-deriving the test. Pure. */
|
|
426
|
+
export function castInRoom(rows, state, room, exclude = null) {
|
|
427
|
+
return [...state.placements.keys()]
|
|
428
|
+
.filter((subject) => subject !== exclude && subject !== room)
|
|
429
|
+
.filter((subject) => state.placements.get(subject).object === room)
|
|
430
|
+
.filter((subject) => isCastMember(rows, state, subject))
|
|
431
|
+
.sort();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A predator eats whatever walks into its room. The marker is a world fact,
|
|
435
|
+
// so which individual is dangerous is the world's business, never this
|
|
436
|
+
// module's.
|
|
437
|
+
const PREDATOR_PREDICATE = "mgx:is-predator";
|
|
438
|
+
|
|
439
|
+
/** The predator standing in `room`, or null — read from the same placements
|
|
440
|
+
* fold every other presence check uses. Pure. */
|
|
441
|
+
function predatorIn(rows, state, room) {
|
|
442
|
+
return castInRoom(rows, state, room)
|
|
443
|
+
.find((subject) => factObjects(rows, subject, PREDATOR_PREDICATE).includes("true")) ?? null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** True when `subject` has left the world: placed at an out-of-play sentinel
|
|
447
|
+
* no room can be called. Its part in the world is finished — every command it
|
|
448
|
+
* gives declines, and its scripted turns stop. Pure. */
|
|
449
|
+
export const isOutOfPlay = (state, subject) => OUT_OF_PLAY_PLACES.has(state.placements.get(subject)?.object);
|
|
450
|
+
|
|
451
|
+
/** WHY `subject` is out of play — "eaten" or "starved" — or null while it is
|
|
452
|
+
* still playing. The two fates end a run the same way and read nothing alike,
|
|
453
|
+
* so anything narrating one needs to tell them apart. Pure. */
|
|
454
|
+
export const outOfPlayReasonOf = (state, subject) => {
|
|
455
|
+
const place = state.placements.get(subject)?.object;
|
|
456
|
+
return OUT_OF_PLAY_PLACES.has(place) ? place : null;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** How a fate reads in a sentence: "the mole-1 has been eaten", "the mole-1 has
|
|
460
|
+
* starved". One phrase per sentinel, so nothing anywhere else has to spell the
|
|
461
|
+
* difference out. Pure. */
|
|
462
|
+
export const outOfPlayPhrase = (subject, reason) =>
|
|
463
|
+
(reason === STARVED_PLACE ? `the ${subject} has starved` : `the ${subject} has been eaten`);
|
|
464
|
+
|
|
349
465
|
/** The room's real affordances — every exit, and every visible object's
|
|
350
466
|
* applicable verb — read from the EXACT SAME data take/open/talk/examine
|
|
351
467
|
* already check (visibleRoomOf, isContainer, isTyped, the placement
|
|
@@ -375,7 +491,7 @@ export function roomAffordances(rows, state, here, actingSubject = "player") {
|
|
|
375
491
|
actions.push(`examine ${subject}`);
|
|
376
492
|
continue;
|
|
377
493
|
}
|
|
378
|
-
if (
|
|
494
|
+
if (isCastMember(rows, state, subject)) {
|
|
379
495
|
actions.push(`talk to ${subject}`);
|
|
380
496
|
continue;
|
|
381
497
|
}
|
|
@@ -465,13 +581,25 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
|
|
|
465
581
|
// trust track record. The side effect is that worldActionRows filters these
|
|
466
582
|
// out of the playable state fold, which is what you want — being told about a
|
|
467
583
|
// stone must never move the stone.
|
|
584
|
+
//
|
|
585
|
+
// A claim can also go out of date, and none of them is ever retracted. Eating
|
|
586
|
+
// the last carrot appends a SECOND claim to the same edge, tagged `:gone`, and
|
|
587
|
+
// appendFacts unions the two tags onto the one fact exactly as it unions any
|
|
588
|
+
// repeat assertion. "The carrot was here on turn 2" and "the carrot is gone on
|
|
589
|
+
// turn 5" are both true; the reader's job is to say which one rules, the same
|
|
590
|
+
// recency question the p2p layer asks of its own tags. So reading knowledge
|
|
591
|
+
// back means reading the newest claim per edge, never the union of every claim
|
|
592
|
+
// ever made.
|
|
593
|
+
|
|
594
|
+
const VOIDED_TESTIMONY_SUFFIX = ":gone";
|
|
468
595
|
|
|
469
|
-
const characterTestimonyTag = (character, k) =>
|
|
596
|
+
const characterTestimonyTag = (character, k, voided = false) =>
|
|
597
|
+
`mud:${character}:turn${k}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
|
|
470
598
|
|
|
471
|
-
async function appendTestimony(memoryDir, { knower, source, thing, k, cache }) {
|
|
599
|
+
async function appendTestimony(memoryDir, { knower, source, thing, k, voided = false, cache }) {
|
|
472
600
|
await appendFacts(memoryDir, [{
|
|
473
601
|
subject: knower, predicate: KNOWS_ABOUT_PREDICATE, object: thing,
|
|
474
|
-
provenance: characterTestimonyTag(source, k),
|
|
602
|
+
provenance: characterTestimonyTag(source, k, voided),
|
|
475
603
|
}]);
|
|
476
604
|
if (cache) cache.rows = null;
|
|
477
605
|
}
|
|
@@ -490,6 +618,98 @@ export async function recordExamined(memoryDir, { observer, thing, k, cache = nu
|
|
|
490
618
|
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, cache });
|
|
491
619
|
}
|
|
492
620
|
|
|
621
|
+
/** Record that `observer` saw `thing` leave the world on turn `k` — it ate the
|
|
622
|
+
* last of it. Written as a fresh claim on the SAME edge an older one already
|
|
623
|
+
* sits on, so the older claim stands untouched and stops being the one that
|
|
624
|
+
* rules. The observer is its own source, the way examining is. */
|
|
625
|
+
export async function recordGone(memoryDir, { observer, thing, k, cache = null }) {
|
|
626
|
+
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, voided: true, cache });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const TESTIMONY_TAG_RE = /^mud:([^:\s]+):turn(\d+)(:gone)?$/;
|
|
630
|
+
const TURN_STAMP_RE = /:turn(\d+)\b/;
|
|
631
|
+
|
|
632
|
+
/** How one provenance segment on a knows-about edge stands as a claim about
|
|
633
|
+
* what `knower` knows: whether the knower vouches for it itself, the turn it
|
|
634
|
+
* was asserted on, and whether it says the thing is gone. A tag that is no
|
|
635
|
+
* character's testimony — a world's own seed fact, a dig spawn — reads as
|
|
636
|
+
* hearsay stamped with whatever turn it carries. */
|
|
637
|
+
function testimonyClaim(segment, knower) {
|
|
638
|
+
const mine = TESTIMONY_TAG_RE.exec(segment);
|
|
639
|
+
const stamp = Number(mine ? mine[2] : (TURN_STAMP_RE.exec(segment)?.[1] ?? 0));
|
|
640
|
+
return {
|
|
641
|
+
firsthand: !!mine && mine[1] === knower,
|
|
642
|
+
turn: Number.isFinite(stamp) ? stamp : 0,
|
|
643
|
+
voided: !!(mine && mine[3]),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Firsthand beats hearsay outright, then the later turn wins, then "gone"
|
|
648
|
+
* takes the tie — an animal that examined a carrot and ate it on one turn ate
|
|
649
|
+
* it second. Tier ABOVE recency is what stops an animal being talked back into
|
|
650
|
+
* a meal it ate itself: a room-mate can tell it about that carrot the turn
|
|
651
|
+
* after, and its own eyes still hold. */
|
|
652
|
+
const outranksClaim = (claim, best) => (
|
|
653
|
+
claim.firsthand !== best.firsthand ? claim.firsthand
|
|
654
|
+
: claim.turn !== best.turn ? claim.turn > best.turn
|
|
655
|
+
: claim.voided && !best.voided
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
/** The claim that rules on one knows-about edge, across every segment its
|
|
659
|
+
* provenance carries. */
|
|
660
|
+
function rulingTestimonyClaim(provenance, knower) {
|
|
661
|
+
let best = null;
|
|
662
|
+
for (const segment of String(provenance || "").split(" | ")) {
|
|
663
|
+
const tag = segment.trim();
|
|
664
|
+
if (!tag) continue;
|
|
665
|
+
const claim = testimonyClaim(tag, knower);
|
|
666
|
+
if (!best || outranksClaim(claim, best)) best = claim;
|
|
667
|
+
}
|
|
668
|
+
return best;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** What `person` knows about NOW: the object of every knows-about edge whose
|
|
672
|
+
* ruling claim still stands, in the order the edges were first written.
|
|
673
|
+
* Nothing is deleted — an edge whose newest claim says the thing is gone just
|
|
674
|
+
* stops reading back. */
|
|
675
|
+
function currentKnowsAboutTopics(rows, person) {
|
|
676
|
+
const topics = [];
|
|
677
|
+
for (const row of rows || []) {
|
|
678
|
+
if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
|
|
679
|
+
if (rulingTestimonyClaim(row.provenance, person)?.voided) continue;
|
|
680
|
+
topics.push(row.object);
|
|
681
|
+
}
|
|
682
|
+
return topics;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Charge `subject` the mass a turn costs it, and place it out of play at the
|
|
687
|
+
* starved sentinel once nothing is left. Returns `{ mass, starved }` — the mass
|
|
688
|
+
* it is left with, and whether that ended its run. Writes nothing and charges
|
|
689
|
+
* nothing when the drain is zero, when the subject is already out of play, or
|
|
690
|
+
* when the world gives it no mass at all (`mass` is then null: a thing with no
|
|
691
|
+
* mass cannot run out of it).
|
|
692
|
+
*
|
|
693
|
+
* The write lands on the world's OWN next turn, read fresh here rather than
|
|
694
|
+
* taken from the caller. A scripted turn runs several world commands, each
|
|
695
|
+
* stamping a turn of its own, so a caller's tick number can trail the world's
|
|
696
|
+
* count — and a mass snapshot stamped behind the newest placement would fold
|
|
697
|
+
* away as stale the moment it was written.
|
|
698
|
+
*/
|
|
699
|
+
export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn, cache = null }) {
|
|
700
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
701
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
702
|
+
const mass = state.masses.get(subject)?.value ?? null;
|
|
703
|
+
if (mass === null || !(drainPerTurn > 0) || isOutOfPlay(state, subject)) return { mass, starved: false };
|
|
704
|
+
const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
|
|
705
|
+
const k = state.turnCount + 1;
|
|
706
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
707
|
+
{ subject: `${subject}@turn${k}`, predicate: MASS_PREDICATE, object: String(left) },
|
|
708
|
+
...(left > 0 ? [] : [{ subject: `${subject}@turn${k}`, predicate: "mgx:currently-in", object: STARVED_PLACE }]),
|
|
709
|
+
], cache);
|
|
710
|
+
return { mass: left, starved: left <= 0 };
|
|
711
|
+
}
|
|
712
|
+
|
|
493
713
|
// ---- the look/inventory digest ----------------------------------------------
|
|
494
714
|
//
|
|
495
715
|
// "look" and "what am I carrying" are generateCompletion calls (the shipped
|
|
@@ -523,6 +743,21 @@ const VIEW_EXCLUDED_PREDICATES = new Set([
|
|
|
523
743
|
// subClassOf drive positional rendering by their own readers, and read as
|
|
524
744
|
// raw triples if they land in room prose.
|
|
525
745
|
"mgx:default-contains", "mgx:default-plane", "rdfs:subClassOf",
|
|
746
|
+
// A screen name is presentation, not scenery — it reads as a raw triple in
|
|
747
|
+
// room prose ("Carrot-1 mgx:display-name carrot") and says nothing the
|
|
748
|
+
// room's own sentences don't already say.
|
|
749
|
+
DISPLAY_NAME_PREDICATE,
|
|
750
|
+
// Which individual is dangerous is the predator mechanic's own wiring; a
|
|
751
|
+
// room look that announced it would give the trap away as a bare triple.
|
|
752
|
+
"mgx:is-predator",
|
|
753
|
+
// The dig mechanic's wiring is the same kind of thing: it tells the verb what
|
|
754
|
+
// a dug room may hold and how far the world reaches, and says nothing about
|
|
755
|
+
// the room anyone is standing in.
|
|
756
|
+
ORIGIN_PREDICATE, DIG_SPAWN_PREDICATE, DEN_SPAWN_PREDICATE, DEN_RESIDENT_PREDICATE,
|
|
757
|
+
DIG_REACH_PREDICATE, DIG_SPAWN_MAX_PREDICATE, DEN_CHANCE_PREDICATE, DEN_RESIDENT_CHANCE_PREDICATE,
|
|
758
|
+
// How fast a turn wears a species down is the mass economy's own wiring, and
|
|
759
|
+
// reads as a bare number in room prose the same way hasMass does.
|
|
760
|
+
MASS_DRAIN_PREDICATE,
|
|
526
761
|
]);
|
|
527
762
|
|
|
528
763
|
const sentenceCase = (term) => String(term).charAt(0).toUpperCase() + String(term).slice(1);
|
|
@@ -573,7 +808,7 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
|
|
|
573
808
|
const isCarryingCharacter = (holder) =>
|
|
574
809
|
isTyped(rows, holder, "person") || state.placements.get(holder)?.predicate === "mgx:currently-in";
|
|
575
810
|
for (const [subject, place] of state.placements) {
|
|
576
|
-
if (place.predicate === "mgx:hidden-in" || place.object
|
|
811
|
+
if (place.predicate === "mgx:hidden-in" || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
577
812
|
if (place.predicate === "mgx:located-in" && place.object === actingSubject) {
|
|
578
813
|
push(actingSubject, "carries the", subject);
|
|
579
814
|
continue;
|
|
@@ -596,18 +831,18 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
|
|
|
596
831
|
// already assumes, so it is left unsaid.
|
|
597
832
|
const POSITION_PHRASE = { "mgx:on-top-of": "is on the", "mgx:on-plane": "is on the", "mgx:under": "is under the" };
|
|
598
833
|
for (const [subject, place] of state.placements) {
|
|
599
|
-
if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || place.object
|
|
834
|
+
if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
600
835
|
const pos = currentPosition(state, subject);
|
|
601
836
|
if (pos && POSITION_PHRASE[pos.predicate]) { push(subject, POSITION_PHRASE[pos.predicate], pos.object); continue; }
|
|
602
837
|
const plane = classDefaultPlane(rows, subject);
|
|
603
838
|
if (plane && plane !== "floor") push(subject, "is usually on the", plane);
|
|
604
839
|
}
|
|
605
|
-
const
|
|
606
|
-
.filter(([, place]) => place.object
|
|
840
|
+
const gone = new Set([...state.placements]
|
|
841
|
+
.filter(([, place]) => OUT_OF_PLAY_PLACES.has(place.object))
|
|
607
842
|
.map(([subject]) => subject));
|
|
608
843
|
for (const row of rows || []) {
|
|
609
844
|
if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
|
|
610
|
-
if (
|
|
845
|
+
if (gone.has(row.subject)) continue; // out of the world entirely
|
|
611
846
|
// Room text comes from the world source only. A merged corpus overlaps a
|
|
612
847
|
// room's own vocabulary ("library rdfs:subClassOf literary study"), and
|
|
613
848
|
// without this those rows leak into the room description as stray sentences.
|
|
@@ -704,12 +939,160 @@ const OPPOSITE_DIRECTION = new Map([
|
|
|
704
939
|
["up", "down"], ["down", "up"],
|
|
705
940
|
]);
|
|
706
941
|
|
|
707
|
-
// What a freshly dug room holds
|
|
708
|
-
//
|
|
709
|
-
//
|
|
942
|
+
// What a freshly dug room holds, and how often a dig opens something better
|
|
943
|
+
// than a bare tunnel. The world names all of it — a room kind declares the
|
|
944
|
+
// kinds a plain dig turns up, how many of them, the richer set a den holds, how
|
|
945
|
+
// often a dig finds one, and which animal lives in it. The numbers below are
|
|
946
|
+
// only the fallback for a world that declares none.
|
|
710
947
|
const DIG_SPAWN_KINDS = ["root", "carrot", "worm"];
|
|
711
948
|
const DIG_SPAWN_MIN = 0;
|
|
712
|
-
const
|
|
949
|
+
const DEFAULT_DIG_SPAWN_MAX = 2;
|
|
950
|
+
const DEFAULT_DEN_CHANCE_IN = 5;
|
|
951
|
+
const DEFAULT_DEN_RESIDENT_CHANCE_IN = 3;
|
|
952
|
+
const DEN_ROOM_CLASS = "den";
|
|
953
|
+
|
|
954
|
+
// How far from the world's origin room a dig may carry it, when the origin
|
|
955
|
+
// writes no reach of its own. Without a cap a burrow sprawls in every direction
|
|
956
|
+
// at once, and an animal twenty hops out has nothing around it, no food it
|
|
957
|
+
// knows of, and no reason to be anywhere — the stranding this bound exists to
|
|
958
|
+
// stop. Six keeps every room inside one pathfinder search of the origin
|
|
959
|
+
// (mud-turn.mjs walks eight hops), so an animal standing at the frontier can
|
|
960
|
+
// always still walk home to the rooms with food in them.
|
|
961
|
+
const DEFAULT_DIG_REACH = 6;
|
|
962
|
+
|
|
963
|
+
/** The NEWEST value `subject` declares under `predicate`, as a number, or null
|
|
964
|
+
* when it declares none. Newest rather than first on purpose: the store is
|
|
965
|
+
* append-only, so a later write is the current truth — the same rule
|
|
966
|
+
* foldWorldState already applies to placements, and what lets an edit to one of
|
|
967
|
+
* these knobs take effect over the world's own seed fact. Pure. */
|
|
968
|
+
function declaredNumber(rows, subject, predicate) {
|
|
969
|
+
const written = factObjects(rows, subject, predicate);
|
|
970
|
+
if (!written.length) return null;
|
|
971
|
+
const value = Number(written[written.length - 1]);
|
|
972
|
+
return Number.isFinite(value) ? value : null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** A positive count `subject` declares under `predicate`, or `fallback` when it
|
|
976
|
+
* declares none (or writes something that is not a usable count). Pure. */
|
|
977
|
+
function declaredCountOr(rows, subject, predicate, fallback) {
|
|
978
|
+
const written = declaredNumber(rows, subject, predicate);
|
|
979
|
+
return written !== null && written > 0 ? written : fallback;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** How many rooms out from its origin this world lets a dig reach — the origin
|
|
983
|
+
* room's own `mgx:dig-reach` fact, or the shipped default when it writes none.
|
|
984
|
+
* Pure. */
|
|
985
|
+
export function digReachOf(rows) {
|
|
986
|
+
const origin = originRoomOf(rows);
|
|
987
|
+
return origin ? declaredCountOr(rows, origin, DIG_REACH_PREDICATE, DEFAULT_DIG_REACH) : DEFAULT_DIG_REACH;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** What one turn costs `subject` in mass, from a `mgx:mass-drain-per-turn` fact
|
|
991
|
+
* on its own class chain (so a whole species is tuned in one line, and one
|
|
992
|
+
* individual can still overrule its species by writing its own). Null when
|
|
993
|
+
* nothing in the chain declares one — a knob nobody set is not a reason to
|
|
994
|
+
* invent a number and starve something with it. Pure. */
|
|
995
|
+
export function massDrainPerTurnOf(rows, subject) {
|
|
996
|
+
for (const kind of objectClassChain(rows, subject)) {
|
|
997
|
+
const written = declaredNumber(rows, kind, MASS_DRAIN_PREDICATE);
|
|
998
|
+
if (written !== null && written >= 0) return written;
|
|
999
|
+
}
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Which way a room of each kind can be dug, and what the room it opens is
|
|
1004
|
+
// typed as. Above ground there is nothing to tunnel sideways through, so the
|
|
1005
|
+
// only dig is straight down into the soil; below ground the burrow spreads
|
|
1006
|
+
// across its own level and can surface again. Digging deeper is left out so
|
|
1007
|
+
// the burrow stays the one level the soil cross-section draws.
|
|
1008
|
+
const DIGGABLE_BY_ROOM_KIND = new Map([
|
|
1009
|
+
["outdoor", new Map([["down", "underground-space"]])],
|
|
1010
|
+
["underground", new Map([
|
|
1011
|
+
["north", "underground-space"],
|
|
1012
|
+
["south", "underground-space"],
|
|
1013
|
+
["east", "underground-space"],
|
|
1014
|
+
["west", "underground-space"],
|
|
1015
|
+
["up", "outdoor-space"],
|
|
1016
|
+
])],
|
|
1017
|
+
["indoor", new Map()],
|
|
1018
|
+
]);
|
|
1019
|
+
|
|
1020
|
+
const DIG_DECLINE_BY_ROOM_KIND = {
|
|
1021
|
+
outdoor: (room, direction) => (direction === "up"
|
|
1022
|
+
? `there's nothing but sky above the ${room}.`
|
|
1023
|
+
: `you can't tunnel ${direction} out here — the ${room} is open ground, not soil to dig through. Dig down to get under it.`),
|
|
1024
|
+
underground: (room) => `the earth below the ${room} is packed solid — this burrow runs one level deep.`,
|
|
1025
|
+
indoor: (room, direction) => `you can't dig ${direction} out of the ${room}.`,
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
/** A room's own kind, from the rdf:type facts the world writes about it:
|
|
1029
|
+
* "outdoor" (the surface), "underground" (the burrow), or "indoor" for a
|
|
1030
|
+
* walled room that says neither. Pure. */
|
|
1031
|
+
export function roomKindOf(rows, room) {
|
|
1032
|
+
const typedAs = (kind) => (rows || []).some((r) => r.subject === room && r.predicate === "rdf:type" && r.object === kind);
|
|
1033
|
+
if (typedAs("outdoor-space")) return "outdoor";
|
|
1034
|
+
if (typedAs("underground-space")) return "underground";
|
|
1035
|
+
return "indoor";
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** The room a world calls its origin — the one every dig is measured from — or
|
|
1039
|
+
* null when it names none. A world with no origin fact is simply not bounded.
|
|
1040
|
+
* Pure. */
|
|
1041
|
+
export function originRoomOf(rows) {
|
|
1042
|
+
return (rows || []).find((r) => r.predicate === ORIGIN_PREDICATE && r.object === "true")?.subject ?? null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** How many exits a walk from the world's origin to `room` crosses, or null
|
|
1046
|
+
* when the world declares no origin or no chain of exits joins the two. Pure. */
|
|
1047
|
+
export function roomDistanceFromOrigin(rows, state, room) {
|
|
1048
|
+
const origin = originRoomOf(rows);
|
|
1049
|
+
if (!origin) return null;
|
|
1050
|
+
if (origin === room) return 0;
|
|
1051
|
+
const seen = new Set([origin]);
|
|
1052
|
+
let frontier = [origin];
|
|
1053
|
+
for (let distance = 1; frontier.length; distance += 1) {
|
|
1054
|
+
const next = [];
|
|
1055
|
+
for (const from of frontier) {
|
|
1056
|
+
for (const target of state.exits.get(from)?.values() ?? []) {
|
|
1057
|
+
if (seen.has(target)) continue;
|
|
1058
|
+
seen.add(target);
|
|
1059
|
+
if (target === room) return distance;
|
|
1060
|
+
next.push(target);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
frontier = next;
|
|
1064
|
+
}
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** True when `room` is as far from the origin as this world digs, or off the
|
|
1069
|
+
* origin's map altogether. A freshly dug room's only other exit is the one
|
|
1070
|
+
* back, so its distance is always this room's plus one — which makes the whole
|
|
1071
|
+
* boundary test a property of where the digger stands, never of the direction
|
|
1072
|
+
* it faces.
|
|
1073
|
+
*
|
|
1074
|
+
* A room the origin cannot reach is the strictest case, not the loosest: it
|
|
1075
|
+
* has no measurable distance, so nothing would ever stop it growing, and a
|
|
1076
|
+
* burrow with no way home is precisely what the bound exists to prevent. A
|
|
1077
|
+
* world that declares no origin at all is a different thing and stays
|
|
1078
|
+
* unbounded. Pure. */
|
|
1079
|
+
function atDigBoundary(rows, state, room) {
|
|
1080
|
+
if (!originRoomOf(rows)) return false;
|
|
1081
|
+
const distance = roomDistanceFromOrigin(rows, state, room);
|
|
1082
|
+
return distance === null || distance >= digReachOf(rows);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Every direction a dig could actually open a room in from `room`: allowed
|
|
1086
|
+
* by the room's own kind, with no exit already written that way, and inside
|
|
1087
|
+
* the world's dig boundary. This is the exact set the dig verb accepts, so a
|
|
1088
|
+
* caller offering these as hints can never suggest a dig the verb would then
|
|
1089
|
+
* refuse. Pure. */
|
|
1090
|
+
export function diggableDirections(rows, state, room) {
|
|
1091
|
+
if (atDigBoundary(rows, state, room)) return [];
|
|
1092
|
+
const exits = state.exits.get(room);
|
|
1093
|
+
return [...(DIGGABLE_BY_ROOM_KIND.get(roomKindOf(rows, room)) ?? new Map()).keys()]
|
|
1094
|
+
.filter((direction) => !exits?.has(direction));
|
|
1095
|
+
}
|
|
713
1096
|
|
|
714
1097
|
const FOOD_CLASS = "food";
|
|
715
1098
|
// A shared reference mass standing in for per-species maxima until the game
|
|
@@ -741,6 +1124,55 @@ function freshRoomId(rows, here, direction) {
|
|
|
741
1124
|
return `${base}-${(rows || []).length + 3}`;
|
|
742
1125
|
}
|
|
743
1126
|
|
|
1127
|
+
/** An unused id for a freshly dug object, reading as its plain kind and a
|
|
1128
|
+
* small number ("carrot-1"). The short id is what keeps a pouch readable:
|
|
1129
|
+
* naming a spawned object after the room it came out of inherits that room's
|
|
1130
|
+
* whole nested dig path ("carrot-sett-1-north-east-east"), which is an id, not
|
|
1131
|
+
* a name anyone can read. `alsoTaken` holds the ids minted earlier in this
|
|
1132
|
+
* same dig, which are not in `rows` yet. Pure. */
|
|
1133
|
+
function freshObjectId(rows, kind, alsoTaken) {
|
|
1134
|
+
const taken = (id) => alsoTaken.has(id) || (rows || []).some((r) => r.subject === id || r.object === id);
|
|
1135
|
+
for (let n = 1; n <= (rows || []).length + 2; n += 1) {
|
|
1136
|
+
if (!taken(`${kind}-${n}`)) return `${kind}-${n}`;
|
|
1137
|
+
}
|
|
1138
|
+
return `${kind}-${(rows || []).length + 3}`;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** The kinds a room kind declares for one of the spawn pools, in the order the
|
|
1142
|
+
* world wrote them, or `fallback` when it declares none. Pure. */
|
|
1143
|
+
function declaredKindsOr(rows, roomClass, predicate, fallback) {
|
|
1144
|
+
const declared = factObjects(rows, roomClass, predicate);
|
|
1145
|
+
return declared.length ? declared : fallback;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** The mass row a freshly minted instance needs, copied off its own class, or
|
|
1149
|
+
* nothing when the class declares no mass. eat reads the instance's mass, so a
|
|
1150
|
+
* dug carrot with none would be worth the flat default however the world
|
|
1151
|
+
* values a carrot. Pure. */
|
|
1152
|
+
function classMassFacts(rows, instance, kind) {
|
|
1153
|
+
const mass = factObjects(rows, kind, MASS_PREDICATE)[0];
|
|
1154
|
+
return mass ? [{ subject: instance, predicate: MASS_PREDICATE, object: mass }] : [];
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** What a dig reads like: a bare tunnel, a scrap or two in the loose earth, or
|
|
1158
|
+
* a den — and, when somebody lives in it, who looked up. Pure. */
|
|
1159
|
+
function digNarration(direction, { isDen, spawned, resident }) {
|
|
1160
|
+
const opened = isDen
|
|
1161
|
+
? `you dig ${direction} and break into a den somebody hollowed out.`
|
|
1162
|
+
: `you dig ${direction} and open up a new room.`;
|
|
1163
|
+
const held = spawned.length
|
|
1164
|
+
? ` ${isDen ? "Stored in it" : "In the loose earth"}: the ${spawned.join(", the ")}.`
|
|
1165
|
+
: " There's nothing in it but bare earth.";
|
|
1166
|
+
return `${opened}${held}${resident ? ` The ${resident} lives here, and looks up as you come through.` : ""}`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** What a thing should be CALLED on screen: its declared display name, else
|
|
1170
|
+
* its own id. A dug object carries one so a pouch can list "carrot" while the
|
|
1171
|
+
* world keeps the distinct id ("carrot-1") every verb resolves against. Pure. */
|
|
1172
|
+
export function displayNameOf(rows, subject) {
|
|
1173
|
+
return factObjects(rows, subject, DISPLAY_NAME_PREDICATE)[0] ?? subject;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
744
1176
|
/** A container's open/locked status, stated plainly, and (only once already
|
|
745
1177
|
* open) its visible contents — the one thing examine/talk's reused
|
|
746
1178
|
* worldDigest call never states on its own, since mgx:is-open is a
|
|
@@ -783,11 +1215,12 @@ function containerDatatypeState(state, object) {
|
|
|
783
1215
|
* thing's current location, a hidden one included: talking to the staff is
|
|
784
1216
|
* the sanctioned way to learn a hiding place, while the where-is aside keeps
|
|
785
1217
|
* declining. `knows-objective` states the quest. `knows-about` topics come
|
|
786
|
-
* back for the caller to digest
|
|
1218
|
+
* back for the caller to digest, each one read from its newest surviving
|
|
1219
|
+
* claim. Pure. */
|
|
787
1220
|
export function personKnowledgeLines(rows, state, person) {
|
|
788
1221
|
const lines = [];
|
|
789
1222
|
for (const objective of factObjects(rows, person, "mgx:knows-objective")) {
|
|
790
|
-
lines.push(`the ${objective} is what you're after — find it and carry it
|
|
1223
|
+
lines.push(`the ${objective} is what you're after — find it and carry it, and the adventure is won.`);
|
|
791
1224
|
}
|
|
792
1225
|
for (const thing of factObjects(rows, person, "mgx:knows-where")) {
|
|
793
1226
|
const place = state.placements.get(thing);
|
|
@@ -796,7 +1229,7 @@ export function personKnowledgeLines(rows, state, person) {
|
|
|
796
1229
|
? `you'll find the ${thing} in the ${place.object}.`
|
|
797
1230
|
: `the ${thing} is in the ${place.object}.`);
|
|
798
1231
|
}
|
|
799
|
-
return { lines, aboutTopics:
|
|
1232
|
+
return { lines, aboutTopics: currentKnowsAboutTopics(rows, person) };
|
|
800
1233
|
}
|
|
801
1234
|
|
|
802
1235
|
/** The FOOD_CLASS things `person` durably knows about — from being told, or
|
|
@@ -807,7 +1240,7 @@ export function personKnowledgeLines(rows, state, person) {
|
|
|
807
1240
|
* food query has no per-topic sub-digest to hand back, so this returns the
|
|
808
1241
|
* plain list of known food things rather than a {lines, topics} pair. Pure. */
|
|
809
1242
|
export function personKnownFoodLines(rows, state, person) {
|
|
810
|
-
return
|
|
1243
|
+
return currentKnowsAboutTopics(rows, person)
|
|
811
1244
|
.filter((thing) => objectClassChain(rows, thing).includes(FOOD_CLASS));
|
|
812
1245
|
}
|
|
813
1246
|
|
|
@@ -833,6 +1266,410 @@ export function personRoomReport(rows, state, person, actingSubject = "player")
|
|
|
833
1266
|
return parts.join(" ");
|
|
834
1267
|
}
|
|
835
1268
|
|
|
1269
|
+
// ---- state-changing verb handlers --------------------------------------------
|
|
1270
|
+
//
|
|
1271
|
+
// One function per state-changing verb (or per shared-precondition group of
|
|
1272
|
+
// verbs), keyed by name in STATE_VERB_HANDLERS below rather than chained as
|
|
1273
|
+
// if/else-if branches: adding a verb means adding a table entry, never
|
|
1274
|
+
// widening an existing conditional. Each handler takes the one `ctx` object
|
|
1275
|
+
// runWorldCommand builds once per call — cmd, the current fold, the taught
|
|
1276
|
+
// action family, and the shared `commit`/`noteFor` closures — so a handler
|
|
1277
|
+
// reads exactly like the branch it replaces, just lifted to its own name.
|
|
1278
|
+
|
|
1279
|
+
async function handleGoVerb(ctx) {
|
|
1280
|
+
const { cmd, rows, state, here, k, family, commit, noteFor, memoryDir, world, cache, actingSubject } = ctx;
|
|
1281
|
+
const target = state.exits.get(here)?.get(cmd.direction);
|
|
1282
|
+
if (!target) {
|
|
1283
|
+
return answer(
|
|
1284
|
+
`there's no exit ${cmd.direction} from the ${here}.`,
|
|
1285
|
+
noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
|
|
1286
|
+
{ miss: true },
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
// A predator eats whatever walks in, and the room it guards is the one
|
|
1290
|
+
// room a move never comes back from — so this write bypasses commit()
|
|
1291
|
+
// entirely: the auto-relook there would describe a room the mover is no
|
|
1292
|
+
// longer standing in, and the world has nobody left to look with.
|
|
1293
|
+
const predator = predatorIn(rows, state, target);
|
|
1294
|
+
if (predator) {
|
|
1295
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
1296
|
+
{ subject: `${actingSubject}@turn${k}`, predicate: "mgx:currently-in", object: CONSUMED_PLACE },
|
|
1297
|
+
], cache);
|
|
1298
|
+
return answer(
|
|
1299
|
+
`you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
|
|
1300
|
+
noteFor(`go — the ${target} holds the predator ${predator}; ${actingSubject} is placed out of play at turn ${k} and takes no further turns`),
|
|
1301
|
+
{ goal: `move through the world (eaten by the ${predator} in the ${target})` },
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
return commit(
|
|
1305
|
+
[{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
|
|
1306
|
+
`you go ${cmd.direction}. Now in the ${target}.`,
|
|
1307
|
+
`go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
|
|
1308
|
+
`move through the world (now in the ${target})`,
|
|
1309
|
+
target,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function handleTakeVerb(ctx) {
|
|
1314
|
+
const { rows, state, here, k, family, object, place, commit, noteFor, actingSubject } = ctx;
|
|
1315
|
+
if (isTyped(rows, object, "room")) {
|
|
1316
|
+
return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
|
|
1317
|
+
}
|
|
1318
|
+
if (carriedBy(state, object, actingSubject)) {
|
|
1319
|
+
return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
|
|
1320
|
+
}
|
|
1321
|
+
if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
|
|
1322
|
+
return answer(
|
|
1323
|
+
`the ${object} is fixed in place — it can't be taken.`,
|
|
1324
|
+
noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
|
|
1325
|
+
{ miss: true },
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (place && place.predicate === "mgx:currently-in" && place.object === here) {
|
|
1329
|
+
return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
|
|
1330
|
+
}
|
|
1331
|
+
if (visibleRoomOf(object, { rows, state }) !== here) {
|
|
1332
|
+
if (backgroundOnlyMention(rows, state, object)) {
|
|
1333
|
+
return answer(
|
|
1334
|
+
`the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
|
|
1335
|
+
noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
|
|
1336
|
+
{ miss: true },
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
return answer(`I don't see a ${object} here.`, noteFor(`take — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`), { miss: true });
|
|
1340
|
+
}
|
|
1341
|
+
return commit(
|
|
1342
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
|
|
1343
|
+
`you take the ${object}.`,
|
|
1344
|
+
`take — the taught "take" family fired; ${object} is now carried`,
|
|
1345
|
+
`carry the ${object}`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
async function handleDropOrGiveVerb(ctx) {
|
|
1350
|
+
const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
|
|
1351
|
+
if (!carriedBy(state, object, actingSubject)) {
|
|
1352
|
+
return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
|
|
1353
|
+
}
|
|
1354
|
+
if (cmd.verb === "drop") {
|
|
1355
|
+
return commit(
|
|
1356
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
|
|
1357
|
+
`you drop the ${object} in the ${here}.`,
|
|
1358
|
+
`drop — the taught "drop" family fired; ${object} rests in the ${here}`,
|
|
1359
|
+
`set the ${object} down`,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
const receiver = cmd.indirectObject;
|
|
1363
|
+
if (!isCastMember(rows, state, receiver) || state.placements.get(receiver)?.object !== here) {
|
|
1364
|
+
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't one of the cast standing in the ${here}; precondition declined by name`), { miss: true });
|
|
1365
|
+
}
|
|
1366
|
+
return commit(
|
|
1367
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
|
|
1368
|
+
`you give the ${object} to the ${receiver}.`,
|
|
1369
|
+
`give — the taught "give" family fired; the ${receiver} holds the ${object}`,
|
|
1370
|
+
`hand the ${object} over`,
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async function handleDigVerb(ctx) {
|
|
1375
|
+
const { cmd, rows, state, here, k, commit, noteFor } = ctx;
|
|
1376
|
+
const direction = cmd.direction;
|
|
1377
|
+
if (state.exits.get(here)?.get(direction)) {
|
|
1378
|
+
return answer(
|
|
1379
|
+
`there's already an exit ${direction} from the ${here}.`,
|
|
1380
|
+
noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
|
|
1381
|
+
{ miss: true },
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
const back = OPPOSITE_DIRECTION.get(direction);
|
|
1385
|
+
if (!back) {
|
|
1386
|
+
return answer(
|
|
1387
|
+
`I don't know which way back a ${direction} tunnel would run.`,
|
|
1388
|
+
noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
|
|
1389
|
+
{ miss: true },
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
const roomKind = roomKindOf(rows, here);
|
|
1393
|
+
const dugKind = (DIGGABLE_BY_ROOM_KIND.get(roomKind) ?? new Map()).get(direction) ?? null;
|
|
1394
|
+
if (!dugKind) {
|
|
1395
|
+
return answer(
|
|
1396
|
+
DIG_DECLINE_BY_ROOM_KIND[roomKind](here, direction),
|
|
1397
|
+
noteFor(`dig — the ${here} is an ${roomKind} room, which cannot be dug ${direction}; declined by the room's own kind`),
|
|
1398
|
+
{ miss: true },
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
if (atDigBoundary(rows, state, here)) {
|
|
1402
|
+
const reach = roomDistanceFromOrigin(rows, state, here);
|
|
1403
|
+
return answer(
|
|
1404
|
+
`the earth ${direction} of the ${here} is packed hard and endless — you have reached the far edge of the burrow.`,
|
|
1405
|
+
noteFor(reach === null
|
|
1406
|
+
? `dig — no chain of exits joins the ${here} to the ${originRoomOf(rows)}, so there is no distance to measure a dig against; declined`
|
|
1407
|
+
: `dig — the ${here} stands ${reach} rooms from the ${originRoomOf(rows)}, and this world digs ${digReachOf(rows)}; declined by distance from the origin`),
|
|
1408
|
+
{ miss: true },
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
const dug = freshRoomId(rows, here, direction);
|
|
1412
|
+
const denChanceIn = declaredCountOr(rows, dugKind, DEN_CHANCE_PREDICATE, DEFAULT_DEN_CHANCE_IN);
|
|
1413
|
+
const isDen = dugKind === "underground-space" && stableIndex(`den:${dug}`, denChanceIn) === 0;
|
|
1414
|
+
const spawnMax = declaredCountOr(rows, dugKind, DIG_SPAWN_MAX_PREDICATE, DEFAULT_DIG_SPAWN_MAX);
|
|
1415
|
+
const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
|
|
1416
|
+
const spawnedKinds = isDen
|
|
1417
|
+
? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
|
|
1418
|
+
: declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
|
|
1419
|
+
const minted = new Set();
|
|
1420
|
+
const spawned = spawnedKinds.map((kind) => {
|
|
1421
|
+
const id = freshObjectId(rows, kind, minted);
|
|
1422
|
+
minted.add(id);
|
|
1423
|
+
return id;
|
|
1424
|
+
});
|
|
1425
|
+
const residentChanceIn = declaredCountOr(rows, dugKind, DEN_RESIDENT_CHANCE_PREDICATE, DEFAULT_DEN_RESIDENT_CHANCE_IN);
|
|
1426
|
+
const residentKind = isDen && stableIndex(`resident:${dug}`, residentChanceIn) === 0
|
|
1427
|
+
? factObjects(rows, dugKind, DEN_RESIDENT_PREDICATE)[0] ?? null
|
|
1428
|
+
: null;
|
|
1429
|
+
const resident = residentKind ? freshObjectId(rows, residentKind, minted) : null;
|
|
1430
|
+
return commit(
|
|
1431
|
+
[
|
|
1432
|
+
{ subject: dug, predicate: "rdf:type", object: "room" },
|
|
1433
|
+
{ subject: dug, predicate: "rdf:type", object: dugKind },
|
|
1434
|
+
...(isDen ? [{ subject: dug, predicate: "rdf:type", object: DEN_ROOM_CLASS }] : []),
|
|
1435
|
+
{ subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
|
|
1436
|
+
{ subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
|
|
1437
|
+
// Typed to its OWN kind, not a flat "portable" — a spawned kind the
|
|
1438
|
+
// world declares rdfs:subClassOf food needs its real class reachable
|
|
1439
|
+
// here for isFood's own objectClassChain walk, or digging up "carrot-1"
|
|
1440
|
+
// would still read as inedible scenery. The class's own mass copies
|
|
1441
|
+
// onto the instance for the same reason: eat reads the instance.
|
|
1442
|
+
...spawnedKinds.flatMap((kind, i) => ([
|
|
1443
|
+
{ subject: spawned[i], predicate: "rdf:type", object: kind },
|
|
1444
|
+
{ subject: spawned[i], predicate: DISPLAY_NAME_PREDICATE, object: kind },
|
|
1445
|
+
{ subject: spawned[i], predicate: "mgx:located-in", object: dug },
|
|
1446
|
+
...classMassFacts(rows, spawned[i], kind),
|
|
1447
|
+
])),
|
|
1448
|
+
// A resident is placed with currently-in, the predicate that makes an
|
|
1449
|
+
// individual one of the cast, and knows about what its own den holds —
|
|
1450
|
+
// so an animal that digs one out has somebody new to ask about food.
|
|
1451
|
+
...(resident ? [
|
|
1452
|
+
{ subject: resident, predicate: "rdf:type", object: residentKind },
|
|
1453
|
+
{ subject: resident, predicate: DISPLAY_NAME_PREDICATE, object: residentKind },
|
|
1454
|
+
{ subject: resident, predicate: "mgx:currently-in", object: dug },
|
|
1455
|
+
...classMassFacts(rows, resident, residentKind),
|
|
1456
|
+
...spawned.map((thing) => ({ subject: resident, predicate: KNOWS_ABOUT_PREDICATE, object: thing })),
|
|
1457
|
+
] : []),
|
|
1458
|
+
],
|
|
1459
|
+
digNarration(direction, { isDen, spawned, resident }),
|
|
1460
|
+
`dig — minted the ${isDen ? `${DEN_ROOM_CLASS} ` : ""}${dugKind} ${dug} with exits both ways (${direction} out, ${back} back)${spawned.length ? `, and ${spawned.length} object(s) in it` : ""}${resident ? `, lived in by ${resident}` : ""}; digging spends the turn, so the digger stays in the ${here}`,
|
|
1461
|
+
`dig ${direction} out of the ${here}`,
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
async function handleEatVerb(ctx) {
|
|
1466
|
+
const { rows, state, here, k, object, commit, noteFor, memoryDir, cache, actingSubject } = ctx;
|
|
1467
|
+
const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
|
|
1468
|
+
if (!present) {
|
|
1469
|
+
return answer(
|
|
1470
|
+
`I don't see a ${object} here.`,
|
|
1471
|
+
noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
|
|
1472
|
+
{ miss: true },
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
|
|
1476
|
+
return answer(
|
|
1477
|
+
`the ${object} isn't food.`,
|
|
1478
|
+
noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
|
|
1479
|
+
{ miss: true },
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
const eaterMass = state.masses.get(actingSubject)?.value ?? null;
|
|
1483
|
+
if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
|
|
1484
|
+
return answer(
|
|
1485
|
+
`you're too full to eat the ${object}.`,
|
|
1486
|
+
noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
|
|
1487
|
+
{ miss: true },
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
|
|
1491
|
+
const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
|
|
1492
|
+
// Eating is the one act that ends a thing, so the eater is the one witness
|
|
1493
|
+
// whose knowledge of it goes out of date on the spot. Every route into the
|
|
1494
|
+
// eat verb — a typed command, a scripted mud turn — passes here, so the
|
|
1495
|
+
// claim gets written once for all of them.
|
|
1496
|
+
await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
|
|
1497
|
+
return commit(
|
|
1498
|
+
[
|
|
1499
|
+
{ subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
|
|
1500
|
+
{ subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
|
|
1501
|
+
],
|
|
1502
|
+
`you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
|
|
1503
|
+
`eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
|
|
1504
|
+
`eat the ${object}`,
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function handlePutVerb(ctx) {
|
|
1509
|
+
const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
|
|
1510
|
+
const container = cmd.indirectObject;
|
|
1511
|
+
if (!carriedBy(state, object, actingSubject)) {
|
|
1512
|
+
return answer(
|
|
1513
|
+
`you're not carrying the ${object}.`,
|
|
1514
|
+
noteFor(`put — ${object} isn't carried; precondition declined by name`),
|
|
1515
|
+
{ miss: true },
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
if (visibleRoomOf(container, { rows, state }) !== here) {
|
|
1519
|
+
return answer(
|
|
1520
|
+
`I don't see a ${container} here.`,
|
|
1521
|
+
noteFor(`put — ${container} isn't visible in the ${here}; declined`),
|
|
1522
|
+
{ miss: true },
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
if (!isContainer(rows, container)) {
|
|
1526
|
+
return answer(
|
|
1527
|
+
`the ${container} doesn't hold things.`,
|
|
1528
|
+
noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
|
|
1529
|
+
{ miss: true },
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
if (!state.openness.get(container)?.open) {
|
|
1533
|
+
return answer(
|
|
1534
|
+
`the ${container} is closed.`,
|
|
1535
|
+
noteFor(`put — the ${container} isn't open; precondition declined by name`),
|
|
1536
|
+
{ miss: true },
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
return commit(
|
|
1540
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
|
|
1541
|
+
`you put the ${object} in the ${container}.`,
|
|
1542
|
+
`put — the taught "put" family fired; the ${object} now sits in the ${container}`,
|
|
1543
|
+
`put the ${object} in the ${container}`,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// open / unlock / close — the container verbs, sharing one handler (all
|
|
1548
|
+
// three keys in STATE_VERB_HANDLERS below point here) because they share a
|
|
1549
|
+
// precondition: presence and container-ness stay hand-checked (visibility
|
|
1550
|
+
// gating, not a state precondition), the same visibleRoomOf check
|
|
1551
|
+
// examine/talk/take already use, not a hand-rolled duplicate. unlock's
|
|
1552
|
+
// instrument match stays fully hand-written below it too — it needs a
|
|
1553
|
+
// third, externally-supplied binding beyond subject/target, which this
|
|
1554
|
+
// retrofit does not attempt. open/close's lock-state and open/closed
|
|
1555
|
+
// checks, and their mgx:is-open write, are taught "fact-value" precond/
|
|
1556
|
+
// effect rows consulted through domain.mjs; only the hidden-contents
|
|
1557
|
+
// reveal (a variable-arity effect over a discovered set) stays hand-written
|
|
1558
|
+
// JS, since no shipped rule shape covers that either.
|
|
1559
|
+
async function handleContainerVerb(ctx) {
|
|
1560
|
+
const { cmd, rows, state, here, k, object, place, commit, noteFor, ruleRows } = ctx;
|
|
1561
|
+
if (visibleRoomOf(object, { rows, state }) !== here) {
|
|
1562
|
+
return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
|
|
1563
|
+
}
|
|
1564
|
+
if (!isContainer(rows, object)) {
|
|
1565
|
+
return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (cmd.verb === "open" || cmd.verb === "close") {
|
|
1569
|
+
const domain = compileDomain(rows, ruleRows);
|
|
1570
|
+
const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
|
|
1571
|
+
const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
|
|
1572
|
+
if (!effect) {
|
|
1573
|
+
return answer(
|
|
1574
|
+
`this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
|
|
1575
|
+
noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
|
|
1576
|
+
{ miss: true },
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
const factState = containerDatatypeState(state, object);
|
|
1580
|
+
const failed = taughtAction.preconds.find((p) => !precondHolds(p, ctx.actingSubject, object, factState, domain));
|
|
1581
|
+
if (failed) {
|
|
1582
|
+
const text = failed.predicate === "mgx:stands-locked-in"
|
|
1583
|
+
? `the ${object} is locked.`
|
|
1584
|
+
: cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
|
|
1585
|
+
return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
|
|
1586
|
+
}
|
|
1587
|
+
const effSubject = roleBinding(effect.subjectRole, ctx.actingSubject, object, domain);
|
|
1588
|
+
const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
|
|
1589
|
+
|
|
1590
|
+
if (cmd.verb === "open") {
|
|
1591
|
+
const revealed = [...state.placements]
|
|
1592
|
+
.filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
|
|
1593
|
+
.map(([thing]) => thing)
|
|
1594
|
+
.sort();
|
|
1595
|
+
return commit(
|
|
1596
|
+
[
|
|
1597
|
+
writeIsOpen,
|
|
1598
|
+
...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
|
|
1599
|
+
],
|
|
1600
|
+
revealed.length
|
|
1601
|
+
? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
|
|
1602
|
+
: `you open the ${object}. It's empty.`,
|
|
1603
|
+
`open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
|
|
1604
|
+
`open the ${object}`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
return commit(
|
|
1608
|
+
[writeIsOpen],
|
|
1609
|
+
`you close the ${object}.`,
|
|
1610
|
+
`close — ${object} closes via the taught "close" family's effect`,
|
|
1611
|
+
`close the ${object}`,
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// unlock
|
|
1616
|
+
if (place.predicate !== "mgx:stands-locked-in") {
|
|
1617
|
+
return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
|
|
1618
|
+
}
|
|
1619
|
+
if (!cmd.instrument) {
|
|
1620
|
+
return answer(
|
|
1621
|
+
`unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
|
|
1622
|
+
noteFor("unlock — no instrument named; asked, never guessed"),
|
|
1623
|
+
{ miss: true },
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
|
|
1627
|
+
if (!required) {
|
|
1628
|
+
return answer(
|
|
1629
|
+
`nothing in this world says what unlocks the ${object}.`,
|
|
1630
|
+
noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
|
|
1631
|
+
{ miss: true },
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
if (!carriedBy(state, cmd.instrument, ctx.actingSubject)) {
|
|
1635
|
+
return answer(
|
|
1636
|
+
`you're not carrying the ${cmd.instrument}.`,
|
|
1637
|
+
noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
|
|
1638
|
+
{ miss: true },
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
if (cmd.instrument !== required) {
|
|
1642
|
+
return answer(
|
|
1643
|
+
`the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
|
|
1644
|
+
noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
|
|
1645
|
+
{ miss: true },
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
return commit(
|
|
1649
|
+
[{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
|
|
1650
|
+
`you unlock the ${object} with the ${required}.`,
|
|
1651
|
+
`unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
|
|
1652
|
+
`unlock the ${object}`,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// The data-driven verb table itself: every state-changing verb this engine
|
|
1657
|
+
// knows, mapped to the handler that runs it. A world's own taught action
|
|
1658
|
+
// family (checked before this table is ever consulted) decides whether the
|
|
1659
|
+
// verb exists in THIS world; this table decides which JS runs once it does.
|
|
1660
|
+
const STATE_VERB_HANDLERS = {
|
|
1661
|
+
go: handleGoVerb,
|
|
1662
|
+
take: handleTakeVerb,
|
|
1663
|
+
drop: handleDropOrGiveVerb,
|
|
1664
|
+
give: handleDropOrGiveVerb,
|
|
1665
|
+
dig: handleDigVerb,
|
|
1666
|
+
eat: handleEatVerb,
|
|
1667
|
+
put: handlePutVerb,
|
|
1668
|
+
open: handleContainerVerb,
|
|
1669
|
+
close: handleContainerVerb,
|
|
1670
|
+
unlock: handleContainerVerb,
|
|
1671
|
+
};
|
|
1672
|
+
|
|
836
1673
|
export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject = "player" }) {
|
|
837
1674
|
const memory = await loadMemory(memoryDir);
|
|
838
1675
|
const rows = readFactRows(memory);
|
|
@@ -854,6 +1691,13 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
854
1691
|
{ miss: true },
|
|
855
1692
|
);
|
|
856
1693
|
}
|
|
1694
|
+
if (OUT_OF_PLAY_PLACES.has(here)) {
|
|
1695
|
+
return answer(
|
|
1696
|
+
`${outOfPlayPhrase(actingSubject, here)} — it takes no more turns in this world.`,
|
|
1697
|
+
noteFor(`${cmd.verb} — ${actingSubject} is placed out of play (${here}); every command it gives declines from here on`),
|
|
1698
|
+
{ miss: true },
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
857
1701
|
|
|
858
1702
|
if (cmd.verb === "look" && !cmd.object) {
|
|
859
1703
|
const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
@@ -903,7 +1747,7 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
903
1747
|
{ miss: true },
|
|
904
1748
|
);
|
|
905
1749
|
}
|
|
906
|
-
const person =
|
|
1750
|
+
const person = isCastMember(rows, state, object);
|
|
907
1751
|
// "look <object>" on a real placed prop is the grounded close look: every
|
|
908
1752
|
// physical fact the world writes about the thing (its placement, its
|
|
909
1753
|
// within-room position, any datatype property — all via the SAME
|
|
@@ -997,310 +1841,18 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
997
1841
|
const object = cmd.object;
|
|
998
1842
|
const place = object ? state.placements.get(object) ?? null : null;
|
|
999
1843
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (!target) {
|
|
1003
|
-
return answer(
|
|
1004
|
-
`there's no exit ${cmd.direction} from the ${here}.`,
|
|
1005
|
-
noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
|
|
1006
|
-
{ miss: true },
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
return commit(
|
|
1010
|
-
[{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
|
|
1011
|
-
`you go ${cmd.direction}. Now in the ${target}.`,
|
|
1012
|
-
`go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
|
|
1013
|
-
`move through the world (now in the ${target})`,
|
|
1014
|
-
target,
|
|
1015
|
-
);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
if (cmd.verb === "take") {
|
|
1019
|
-
if (isTyped(rows, object, "room")) {
|
|
1020
|
-
return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
|
|
1021
|
-
}
|
|
1022
|
-
if (carriedBy(state, object, actingSubject)) {
|
|
1023
|
-
return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
|
|
1024
|
-
}
|
|
1025
|
-
if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
|
|
1026
|
-
return answer(
|
|
1027
|
-
`the ${object} is fixed in place — it can't be taken.`,
|
|
1028
|
-
noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
|
|
1029
|
-
{ miss: true },
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
if (place && place.predicate === "mgx:currently-in" && place.object === here) {
|
|
1033
|
-
return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
|
|
1034
|
-
}
|
|
1035
|
-
if (visibleRoomOf(object, { rows, state }) !== here) {
|
|
1036
|
-
if (backgroundOnlyMention(rows, state, object)) {
|
|
1037
|
-
return answer(
|
|
1038
|
-
`the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
|
|
1039
|
-
noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
|
|
1040
|
-
{ miss: true },
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
return answer(`I don't see a ${object} here.`, noteFor(`take — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`), { miss: true });
|
|
1044
|
-
}
|
|
1045
|
-
return commit(
|
|
1046
|
-
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
|
|
1047
|
-
`you take the ${object}.`,
|
|
1048
|
-
`take — the taught "take" family fired; ${object} is now carried`,
|
|
1049
|
-
`carry the ${object}`,
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
if (cmd.verb === "drop" || cmd.verb === "give") {
|
|
1054
|
-
if (!carriedBy(state, object, actingSubject)) {
|
|
1055
|
-
return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
|
|
1056
|
-
}
|
|
1057
|
-
if (cmd.verb === "drop") {
|
|
1058
|
-
return commit(
|
|
1059
|
-
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
|
|
1060
|
-
`you drop the ${object} in the ${here}.`,
|
|
1061
|
-
`drop — the taught "drop" family fired; ${object} rests in the ${here}`,
|
|
1062
|
-
`set the ${object} down`,
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1065
|
-
const receiver = cmd.indirectObject;
|
|
1066
|
-
if (!isTyped(rows, receiver, "person") || state.placements.get(receiver)?.object !== here) {
|
|
1067
|
-
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't a person in the ${here}; precondition declined by name`), { miss: true });
|
|
1068
|
-
}
|
|
1069
|
-
return commit(
|
|
1070
|
-
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
|
|
1071
|
-
`you give the ${object} to the ${receiver}.`,
|
|
1072
|
-
`give — the taught "give" family fired; the ${receiver} holds the ${object}`,
|
|
1073
|
-
`hand the ${object} over`,
|
|
1074
|
-
);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
if (cmd.verb === "dig") {
|
|
1078
|
-
const direction = cmd.direction;
|
|
1079
|
-
if (state.exits.get(here)?.get(direction)) {
|
|
1080
|
-
return answer(
|
|
1081
|
-
`there's already an exit ${direction} from the ${here}.`,
|
|
1082
|
-
noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
|
|
1083
|
-
{ miss: true },
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
const back = OPPOSITE_DIRECTION.get(direction);
|
|
1087
|
-
if (!back) {
|
|
1088
|
-
return answer(
|
|
1089
|
-
`I don't know which way back a ${direction} tunnel would run.`,
|
|
1090
|
-
noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
|
|
1091
|
-
{ miss: true },
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
const dug = freshRoomId(rows, here, direction);
|
|
1095
|
-
const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, DIG_SPAWN_MAX - DIG_SPAWN_MIN + 1);
|
|
1096
|
-
const spawnedKinds = DIG_SPAWN_KINDS.slice(0, spawnCount);
|
|
1097
|
-
const spawned = spawnedKinds.map((kind) => `${kind}-${dug}`);
|
|
1098
|
-
return commit(
|
|
1099
|
-
[
|
|
1100
|
-
{ subject: dug, predicate: "rdf:type", object: "room" },
|
|
1101
|
-
{ subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
|
|
1102
|
-
{ subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
|
|
1103
|
-
// Typed to its OWN kind, not a flat "portable" — a spawned kind the
|
|
1104
|
-
// world already declares rdfs:subClassOf food (DIG_SPAWN_KINDS may
|
|
1105
|
-
// carry one) needs its real class reachable here for isFood's own
|
|
1106
|
-
// objectClassChain walk, or digging up "carrot-..." would still read
|
|
1107
|
-
// as inedible scenery.
|
|
1108
|
-
...spawnedKinds.flatMap((kind, i) => ([
|
|
1109
|
-
{ subject: spawned[i], predicate: "rdf:type", object: kind },
|
|
1110
|
-
{ subject: spawned[i], predicate: "mgx:located-in", object: dug },
|
|
1111
|
-
])),
|
|
1112
|
-
],
|
|
1113
|
-
spawned.length
|
|
1114
|
-
? `you dig ${direction} and open up a new room. In the loose earth: the ${spawned.join(", the ")}.`
|
|
1115
|
-
: `you dig ${direction} and open up a new room. There's nothing in it but bare earth.`,
|
|
1116
|
-
`dig — minted the room ${dug} with exits both ways (${direction} out, ${back} back)${spawned.length ? `, and ${spawned.length} object(s) in it` : ""}; digging spends the turn, so the digger stays in the ${here}`,
|
|
1117
|
-
`dig ${direction} out of the ${here}`,
|
|
1118
|
-
);
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
if (cmd.verb === "eat") {
|
|
1122
|
-
const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
|
|
1123
|
-
if (!present) {
|
|
1124
|
-
return answer(
|
|
1125
|
-
`I don't see a ${object} here.`,
|
|
1126
|
-
noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
|
|
1127
|
-
{ miss: true },
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
|
|
1131
|
-
return answer(
|
|
1132
|
-
`the ${object} isn't food.`,
|
|
1133
|
-
noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
|
|
1134
|
-
{ miss: true },
|
|
1135
|
-
);
|
|
1136
|
-
}
|
|
1137
|
-
const eaterMass = state.masses.get(actingSubject)?.value ?? null;
|
|
1138
|
-
if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
|
|
1139
|
-
return answer(
|
|
1140
|
-
`you're too full to eat the ${object}.`,
|
|
1141
|
-
noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
|
|
1142
|
-
{ miss: true },
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
|
|
1146
|
-
const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
|
|
1147
|
-
return commit(
|
|
1148
|
-
[
|
|
1149
|
-
{ subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
|
|
1150
|
-
{ subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
|
|
1151
|
-
],
|
|
1152
|
-
`you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
|
|
1153
|
-
`eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
|
|
1154
|
-
`eat the ${object}`,
|
|
1155
|
-
);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
if (cmd.verb === "put") {
|
|
1159
|
-
const container = cmd.indirectObject;
|
|
1160
|
-
if (!carriedBy(state, object, actingSubject)) {
|
|
1161
|
-
return answer(
|
|
1162
|
-
`you're not carrying the ${object}.`,
|
|
1163
|
-
noteFor(`put — ${object} isn't carried; precondition declined by name`),
|
|
1164
|
-
{ miss: true },
|
|
1165
|
-
);
|
|
1166
|
-
}
|
|
1167
|
-
if (visibleRoomOf(container, { rows, state }) !== here) {
|
|
1168
|
-
return answer(
|
|
1169
|
-
`I don't see a ${container} here.`,
|
|
1170
|
-
noteFor(`put — ${container} isn't visible in the ${here}; declined`),
|
|
1171
|
-
{ miss: true },
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
if (!isContainer(rows, container)) {
|
|
1175
|
-
return answer(
|
|
1176
|
-
`the ${container} doesn't hold things.`,
|
|
1177
|
-
noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
|
|
1178
|
-
{ miss: true },
|
|
1179
|
-
);
|
|
1180
|
-
}
|
|
1181
|
-
if (!state.openness.get(container)?.open) {
|
|
1182
|
-
return answer(
|
|
1183
|
-
`the ${container} is closed.`,
|
|
1184
|
-
noteFor(`put — the ${container} isn't open; precondition declined by name`),
|
|
1185
|
-
{ miss: true },
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
return commit(
|
|
1189
|
-
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
|
|
1190
|
-
`you put the ${object} in the ${container}.`,
|
|
1191
|
-
`put — the taught "put" family fired; the ${object} now sits in the ${container}`,
|
|
1192
|
-
`put the ${object} in the ${container}`,
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// open / unlock / close — the container verbs. presence and container-ness
|
|
1197
|
-
// stay hand-checked here (visibility gating, not a state precondition);
|
|
1198
|
-
// unlock's instrument match stays fully hand-written below it too — it
|
|
1199
|
-
// needs a third, externally-supplied binding beyond subject/target, which
|
|
1200
|
-
// this retrofit does not attempt. open/close's lock-state and open/closed
|
|
1201
|
-
// checks, and their mgx:is-open write, are now taught "fact-value"
|
|
1202
|
-
// precond/effect rows consulted through domain.mjs below; only the
|
|
1203
|
-
// hidden-contents reveal (a variable-arity effect over a discovered set)
|
|
1204
|
-
// stays hand-written JS, since no shipped rule shape covers that either.
|
|
1205
|
-
// Presence reuses visibleRoomOf (the SAME check examine/talk/take already
|
|
1206
|
-
// use), not a hand-rolled duplicate: an earlier version of this check
|
|
1207
|
-
// excluded mgx:currently-in outright, so a genuinely-present NPC (placed
|
|
1208
|
-
// that way, not fixed-in/stands-locked-in) fell into "I don't see a X
|
|
1209
|
-
// here" instead of reaching the isContainer check just below, which would
|
|
1210
|
-
// have honestly said "the X doesn't open."
|
|
1211
|
-
if (visibleRoomOf(object, { rows, state }) !== here) {
|
|
1212
|
-
return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
|
|
1213
|
-
}
|
|
1214
|
-
if (!isContainer(rows, object)) {
|
|
1215
|
-
return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
if (cmd.verb === "open" || cmd.verb === "close") {
|
|
1219
|
-
const domain = compileDomain(rows, ruleRows);
|
|
1220
|
-
const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
|
|
1221
|
-
const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
|
|
1222
|
-
if (!effect) {
|
|
1223
|
-
return answer(
|
|
1224
|
-
`this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
|
|
1225
|
-
noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
|
|
1226
|
-
{ miss: true },
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
1229
|
-
const factState = containerDatatypeState(state, object);
|
|
1230
|
-
const failed = taughtAction.preconds.find((p) => !precondHolds(p, actingSubject, object, factState, domain));
|
|
1231
|
-
if (failed) {
|
|
1232
|
-
const text = failed.predicate === "mgx:stands-locked-in"
|
|
1233
|
-
? `the ${object} is locked.`
|
|
1234
|
-
: cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
|
|
1235
|
-
return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
|
|
1236
|
-
}
|
|
1237
|
-
const effSubject = roleBinding(effect.subjectRole, actingSubject, object, domain);
|
|
1238
|
-
const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
|
|
1239
|
-
|
|
1240
|
-
if (cmd.verb === "open") {
|
|
1241
|
-
const revealed = [...state.placements]
|
|
1242
|
-
.filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
|
|
1243
|
-
.map(([thing]) => thing)
|
|
1244
|
-
.sort();
|
|
1245
|
-
return commit(
|
|
1246
|
-
[
|
|
1247
|
-
writeIsOpen,
|
|
1248
|
-
...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
|
|
1249
|
-
],
|
|
1250
|
-
revealed.length
|
|
1251
|
-
? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
|
|
1252
|
-
: `you open the ${object}. It's empty.`,
|
|
1253
|
-
`open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
|
|
1254
|
-
`open the ${object}`,
|
|
1255
|
-
);
|
|
1256
|
-
}
|
|
1257
|
-
return commit(
|
|
1258
|
-
[writeIsOpen],
|
|
1259
|
-
`you close the ${object}.`,
|
|
1260
|
-
`close — ${object} closes via the taught "close" family's effect`,
|
|
1261
|
-
`close the ${object}`,
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// unlock
|
|
1266
|
-
if (place.predicate !== "mgx:stands-locked-in") {
|
|
1267
|
-
return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
|
|
1268
|
-
}
|
|
1269
|
-
if (!cmd.instrument) {
|
|
1844
|
+
const handler = STATE_VERB_HANDLERS[cmd.verb];
|
|
1845
|
+
if (!handler) {
|
|
1270
1846
|
return answer(
|
|
1271
|
-
`
|
|
1272
|
-
noteFor(
|
|
1847
|
+
`this world doesn't know how to run the verb "${cmd.verb}" yet.`,
|
|
1848
|
+
noteFor(`${cmd.verb} — a taught family exists but no handler is wired for it; honest decline`),
|
|
1273
1849
|
{ miss: true },
|
|
1274
1850
|
);
|
|
1275
1851
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
|
|
1281
|
-
{ miss: true },
|
|
1282
|
-
);
|
|
1283
|
-
}
|
|
1284
|
-
if (!carriedBy(state, cmd.instrument, actingSubject)) {
|
|
1285
|
-
return answer(
|
|
1286
|
-
`you're not carrying the ${cmd.instrument}.`,
|
|
1287
|
-
noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
|
|
1288
|
-
{ miss: true },
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
if (cmd.instrument !== required) {
|
|
1292
|
-
return answer(
|
|
1293
|
-
`the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
|
|
1294
|
-
noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
|
|
1295
|
-
{ miss: true },
|
|
1296
|
-
);
|
|
1297
|
-
}
|
|
1298
|
-
return commit(
|
|
1299
|
-
[{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
|
|
1300
|
-
`you unlock the ${object} with the ${required}.`,
|
|
1301
|
-
`unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
|
|
1302
|
-
`unlock the ${object}`,
|
|
1303
|
-
);
|
|
1852
|
+
return handler({
|
|
1853
|
+
cmd, rows, state, here, k, family, families, ruleRows, object, place,
|
|
1854
|
+
commit, noteFor, actingSubject, memoryDir, world, cache, graph, memory,
|
|
1855
|
+
});
|
|
1304
1856
|
}
|
|
1305
1857
|
|
|
1306
1858
|
// A mid-game "where is X" aside. Optional trailing "now": the question means
|
|
@@ -1338,10 +1890,10 @@ async function worldWhereAnswer(line, { memoryDir, actingSubject = "player" }) {
|
|
|
1338
1890
|
{ miss: true, goal: `locate the ${thing}` },
|
|
1339
1891
|
);
|
|
1340
1892
|
}
|
|
1341
|
-
if (place.object
|
|
1893
|
+
if (OUT_OF_PLAY_PLACES.has(place.object)) {
|
|
1342
1894
|
return answer(
|
|
1343
|
-
|
|
1344
|
-
`ADVENTURE — where-aside: ${thing}
|
|
1895
|
+
`${outOfPlayPhrase(thing, place.object)} — it's gone from the world.`,
|
|
1896
|
+
`ADVENTURE — where-aside: ${thing} is out of play (${place.object}), so it has no place left to name`,
|
|
1345
1897
|
{ goal: `locate the ${thing}` },
|
|
1346
1898
|
);
|
|
1347
1899
|
}
|
|
@@ -1396,11 +1948,28 @@ async function worldOpennessAnswer(line, { memoryDir }) {
|
|
|
1396
1948
|
const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
|
|
1397
1949
|
const WORLD_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
|
|
1398
1950
|
const WORLD_QUEST_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:quest|goal|objective|mission|aim)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do)|what\s+do\s+i\s+do\s+here)[?.!\s]*$/i;
|
|
1951
|
+
// "who is here" — the room's cast, the question a shared world invites the
|
|
1952
|
+
// moment a second animal walks in. Answered from the same currently-in
|
|
1953
|
+
// placements the talk verb resolves against, so who is named is exactly who
|
|
1954
|
+
// can be talked to.
|
|
1955
|
+
const WORLD_WHO_HERE_RE =
|
|
1956
|
+
/^(?:who(?:'s|\s+is|\s+are)\s+(?:else\s+)?(?:here|in\s+(?:the\s+|this\s+)?room|with\s+me)|who\s+else\s+is\s+(?:here|around))[?.!\s]*$/i;
|
|
1399
1957
|
// "what food do you know about" and its natural variants — the asking
|
|
1400
1958
|
// character's OWN durable food knowledge (personKnownFoodLines), never the
|
|
1401
|
-
// whole world's food.
|
|
1402
|
-
// about
|
|
1403
|
-
|
|
1959
|
+
// whole world's food. The trailing "about" is optional, "know" swaps for
|
|
1960
|
+
// "found"/"seen"/"heard about", and the "what do you know about food"
|
|
1961
|
+
// inversion and a plain yes/no lead-in both count: every one of these is the
|
|
1962
|
+
// same question, and a phrasing this lane doesn't recognise leaves the world
|
|
1963
|
+
// entirely and comes back answered as vocabulary.
|
|
1964
|
+
const WORLD_KNOWN_FOOD_RE = new RegExp(
|
|
1965
|
+
"^(?:"
|
|
1966
|
+
+ "what\\s+foods?\\s+(?:do\\s+you\\s+know(?:\\s+about)?|have\\s+you\\s+(?:found|seen|heard\\s+(?:about|of))|do\\s+you\\s+know\\s+of)"
|
|
1967
|
+
+ "|what\\s+do\\s+you\\s+know\\s+about\\s+(?:any\\s+)?foods?"
|
|
1968
|
+
+ "|do\\s+you\\s+know\\s+(?:about|of)\\s+(?:any\\s+)?foods?"
|
|
1969
|
+
+ "|where\\s+is\\s+(?:the\\s+)?food"
|
|
1970
|
+
+ ")[?.!\\s]*$",
|
|
1971
|
+
"i",
|
|
1972
|
+
);
|
|
1404
1973
|
|
|
1405
1974
|
/** The in-game orientation asides, answered from the world fold: the player's
|
|
1406
1975
|
* room, the room's real affordances, and the world's objective. Null when the
|
|
@@ -1410,12 +1979,24 @@ async function worldContextAnswer(line, { memoryDir, actingSubject = "player" })
|
|
|
1410
1979
|
const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
|
|
1411
1980
|
const asksOptions = WORLD_OPTIONS_RE.test(l);
|
|
1412
1981
|
const asksQuest = WORLD_QUEST_RE.test(l);
|
|
1413
|
-
|
|
1982
|
+
const asksWhoIsHere = WORLD_WHO_HERE_RE.test(l);
|
|
1983
|
+
if (!asksWhere && !asksOptions && !asksQuest && !asksWhoIsHere) return null;
|
|
1414
1984
|
let rows;
|
|
1415
1985
|
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
1416
1986
|
const state = foldWorldState(worldActionRows(rows));
|
|
1417
1987
|
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
1418
1988
|
|
|
1989
|
+
if (asksWhoIsHere) {
|
|
1990
|
+
const cast = here ? castInRoom(rows, state, here, actingSubject) : [];
|
|
1991
|
+
return answer(
|
|
1992
|
+
cast.length
|
|
1993
|
+
? `here with you in the ${here}: the ${cast.join(", the ")}. You can talk to ${cast.length > 1 ? "any of them" : `the ${cast[0]}`}.`
|
|
1994
|
+
: `nobody else is${here ? ` in the ${here}` : " here"} right now.`,
|
|
1995
|
+
`ADVENTURE — who-is-here aside: the ${here}'s cast from the current placements fold, the same set the talk verb resolves against`,
|
|
1996
|
+
{ goal: "see who else is here" },
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1419
2000
|
if (asksWhere) {
|
|
1420
2001
|
return here
|
|
1421
2002
|
? answer(`you are in the ${here}.`, "ADVENTURE — where-am-I aside: the player's own room from the current world fold", { goal: "check where you are" })
|
|
@@ -1473,6 +2054,39 @@ async function worldKnownFoodAnswer(line, { memoryDir, actingSubject = "player"
|
|
|
1473
2054
|
);
|
|
1474
2055
|
}
|
|
1475
2056
|
|
|
2057
|
+
// A question that names one of the world's OWN minted ids — "sett-1",
|
|
2058
|
+
// "groundhog-1", "carrot-2" — can only be about this world: nothing else in
|
|
2059
|
+
// the session has ever heard that token. So when no world shape matched it,
|
|
2060
|
+
// the fall-through is a plain misroute, and in a session with no code graph it
|
|
2061
|
+
// comes back as the code-graph wall, which says nothing true about a burrow.
|
|
2062
|
+
// The gate is the hyphen: a world id that is a plain dictionary word ("lamp",
|
|
2063
|
+
// "garden") stays out of this, so an ordinary mid-game question about an
|
|
2064
|
+
// ordinary word keeps the lane it has always had.
|
|
2065
|
+
const WORLD_QUESTION_LEAD_RE =
|
|
2066
|
+
/^(?:who|what|where|which|how|why|when|tell\s+me|describe|do\s+you|does|is|are|can\s+you|any)\b/i;
|
|
2067
|
+
const WORLD_MINTED_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
2068
|
+
|
|
2069
|
+
/** A digest about the world-minted id a question names, or null when the line
|
|
2070
|
+
* is not a question, names none, or the world places nothing by that name. */
|
|
2071
|
+
async function worldMentionAnswer(line, { memoryDir, graph, actingSubject = "player" }) {
|
|
2072
|
+
const l = String(line).trim();
|
|
2073
|
+
if (!/\?\s*$/.test(l) && !WORLD_QUESTION_LEAD_RE.test(l)) return null;
|
|
2074
|
+
const spoken = new Set(l.toLowerCase().replace(/[?.!,;:"']/g, " ").split(/\s+/).filter(Boolean));
|
|
2075
|
+
let memory;
|
|
2076
|
+
try { memory = await loadMemory(memoryDir); } catch { return null; }
|
|
2077
|
+
const rows = readFactRows(memory);
|
|
2078
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2079
|
+
const named = worldIndividualNames(rows)
|
|
2080
|
+
.find((subject) => WORLD_MINTED_ID_RE.test(subject) && spoken.has(subject));
|
|
2081
|
+
if (!named) return null;
|
|
2082
|
+
const digest = await worldDigest(named, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
2083
|
+
return answer(
|
|
2084
|
+
digest ?? `nothing more about the ${named} is written down yet.`,
|
|
2085
|
+
`ADVENTURE — world-mention aside: "${named}" is an id this world minted, so the question is the world's to answer; digested from the current fold`,
|
|
2086
|
+
{ goal: `find out about the ${named}` },
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1476
2090
|
async function inventoryAnswer({ memoryDir, graph, actingSubject = "player" }) {
|
|
1477
2091
|
const memory = await loadMemory(memoryDir);
|
|
1478
2092
|
const rows = readFactRows(memory);
|
|
@@ -1577,6 +2191,78 @@ async function bindPronouns(cmd, { discourseHolder, memoryDir, actingSubject = "
|
|
|
1577
2191
|
return { cmd: bound };
|
|
1578
2192
|
}
|
|
1579
2193
|
|
|
2194
|
+
// ---- the world's own vocabulary ----------------------------------------------
|
|
2195
|
+
//
|
|
2196
|
+
// A world's minted ids are words only that world knows. "groundhog-1" is in no
|
|
2197
|
+
// dictionary, so the parser's lexicon gate rejects "talk to groundhog-1" as an
|
|
2198
|
+
// undeclared word and the whole command dies before the talk verb ever sees
|
|
2199
|
+
// it. Declaring those ids as PROPER NAMES for the duration of a world command
|
|
2200
|
+
// fixes that: a proper name outranks every other category, so the id resolves
|
|
2201
|
+
// as itself. Ids the core lexicon already knows are left out, so no ordinary
|
|
2202
|
+
// word changes category because a world happens to use it — and the extension
|
|
2203
|
+
// is scoped to this lane, so the teach and ask lanes keep the plain lexicon.
|
|
2204
|
+
|
|
2205
|
+
let worldLexiconCache = { key: null, base: null, lexicon: null };
|
|
2206
|
+
|
|
2207
|
+
function worldLexicon(rows, base) {
|
|
2208
|
+
const names = worldIndividualNames(rows).filter((name) => !classify(name, base));
|
|
2209
|
+
const key = names.join("");
|
|
2210
|
+
if (worldLexiconCache.base === base && worldLexiconCache.key === key) return worldLexiconCache.lexicon;
|
|
2211
|
+
const lexicon = withProperNames(base, names);
|
|
2212
|
+
worldLexiconCache = { key, base, lexicon };
|
|
2213
|
+
return lexicon;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
async function worldAwareLexicon(memoryDir, lexicon) {
|
|
2217
|
+
const base = lexicon ?? loadLexicon();
|
|
2218
|
+
try {
|
|
2219
|
+
return worldLexicon(readFactRows(await loadMemory(memoryDir)), base);
|
|
2220
|
+
} catch {
|
|
2221
|
+
return base;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// ---- the vocative: naming who the line is addressed to ------------------------
|
|
2226
|
+
//
|
|
2227
|
+
// Give a window a character's name and players start using it: "groundhog-1
|
|
2228
|
+
// what do you know about food", "mole-1, dig north". The name is who the line
|
|
2229
|
+
// is addressed to, not part of the question — but it makes the line fit no
|
|
2230
|
+
// world shape at all, so the whole turn leaves this lane and comes back
|
|
2231
|
+
// answered as something else entirely (a code question, in a session with no
|
|
2232
|
+
// code graph). Stripping a vocative that names one of the world's OWN placed
|
|
2233
|
+
// individuals costs one fold read, and only on a line that has already failed
|
|
2234
|
+
// on its own terms.
|
|
2235
|
+
|
|
2236
|
+
const escapeForRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
2237
|
+
|
|
2238
|
+
/** `line` with a leading or trailing vocative naming a placed world
|
|
2239
|
+
* individual removed, or null when it carries none (or when the name is the
|
|
2240
|
+
* whole line, which is a bare mention, not an address). Pure. */
|
|
2241
|
+
export function withoutWorldVocative(line, names) {
|
|
2242
|
+
const l = String(line).trim();
|
|
2243
|
+
for (const name of names) {
|
|
2244
|
+
const escaped = escapeForRegExp(name);
|
|
2245
|
+
const leading = new RegExp(`^${escaped}\\s*[,:;]?\\s+`, "i");
|
|
2246
|
+
if (leading.test(l)) {
|
|
2247
|
+
const rest = l.replace(leading, "").trim();
|
|
2248
|
+
if (rest) return rest;
|
|
2249
|
+
}
|
|
2250
|
+
const trailing = new RegExp(`[\\s,]+${escaped}\\s*([?.!]*)$`, "i");
|
|
2251
|
+
if (trailing.test(l)) {
|
|
2252
|
+
const rest = l.replace(trailing, "$1").trim();
|
|
2253
|
+
if (rest) return rest;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async function addressedLine(line, { memoryDir }) {
|
|
2260
|
+
let rows;
|
|
2261
|
+
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
2262
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2263
|
+
return withoutWorldVocative(line, [...state.placements.keys()].sort());
|
|
2264
|
+
}
|
|
2265
|
+
|
|
1580
2266
|
// ---- the lane ----------------------------------------------------------------
|
|
1581
2267
|
|
|
1582
2268
|
/**
|
|
@@ -1644,13 +2330,29 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1644
2330
|
note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
|
|
1645
2331
|
};
|
|
1646
2332
|
}
|
|
2333
|
+
const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2334
|
+
if (direct) return direct;
|
|
2335
|
+
const addressed = await addressedLine(line, { memoryDir });
|
|
2336
|
+
if (addressed) {
|
|
2337
|
+
const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2338
|
+
if (readdressed) return readdressed;
|
|
2339
|
+
}
|
|
2340
|
+
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/** One line against a LIVE world: inventory, an imperative command, then the
|
|
2344
|
+
* in-game asides. Null when the world has no answer for it, which is what
|
|
2345
|
+
* lets an ordinary mid-game question keep its own lane. Split out from the
|
|
2346
|
+
* lane itself so a line carrying a vocative can be re-offered here once,
|
|
2347
|
+
* stripped, without the two paths ever drifting apart. */
|
|
2348
|
+
async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject }) {
|
|
1647
2349
|
if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph, actingSubject });
|
|
1648
|
-
const parsed = parseImperative(line,
|
|
2350
|
+
const parsed = parseImperative(line, await worldAwareLexicon(memoryDir, lexicon));
|
|
1649
2351
|
if (parsed) {
|
|
1650
2352
|
const bound = await bindPronouns(parsed, { discourseHolder, memoryDir, actingSubject });
|
|
1651
2353
|
if (bound.nudge) return bound.nudge;
|
|
1652
2354
|
const cmd = bound.cmd;
|
|
1653
|
-
const result = await runWorldCommand(cmd, { world
|
|
2355
|
+
const result = await runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject });
|
|
1654
2356
|
// The object a command SUCCESSFULLY named registers as a discourse referent
|
|
1655
2357
|
// a later pronoun binds to — so "look lamp" then "examine it" reads the
|
|
1656
2358
|
// lamp, and "talk to housekeeper" makes "him"/"her" the housekeeper. A miss
|
|
@@ -1682,5 +2384,7 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1682
2384
|
if (contextAside) return contextAside;
|
|
1683
2385
|
const knownFoodAside = await worldKnownFoodAnswer(line, { memoryDir, actingSubject });
|
|
1684
2386
|
if (knownFoodAside) return knownFoodAside;
|
|
2387
|
+
const mentionAside = await worldMentionAnswer(line, { memoryDir, graph, actingSubject });
|
|
2388
|
+
if (mentionAside) return mentionAside;
|
|
1685
2389
|
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
1686
2390
|
}
|