@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/sprites/src/sprite-facts.jsonl +18 -0
- package/corpus/worlds/manifest.json +5 -5
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/src/mud-garden.jsonl +34 -1
- package/package.json +1 -1
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +26 -5
- package/src/domain/grammar/lexicon.mjs +13 -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/sprite-map.mjs +10 -2
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +192 -39
- package/src/services/adventure.mjs +673 -42
- package/src/services/chat-page-viz.mjs +1060 -7
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +264 -87
- package/src/services/mud-viz.mjs +1690 -387
- package/src/services/p2p-room.mjs +559 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +56 -56
- package/src/surfaces/web/mud-browser-entry.mjs +184 -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,20 @@ export function worldActionRows(rows) {
|
|
|
205
227
|
});
|
|
206
228
|
}
|
|
207
229
|
|
|
230
|
+
/** Every individual the world names — its rooms, its cast, its props, and
|
|
231
|
+
* anything dug up since — as the plain id strings a parser has to have
|
|
232
|
+
* DECLARED before it can resolve them. @turnN snapshots are skipped: a
|
|
233
|
+
* snapshot only ever repeats a subject its base row already named. Pure. */
|
|
234
|
+
export function worldIndividualNames(rows) {
|
|
235
|
+
const names = new Set();
|
|
236
|
+
for (const row of rows || []) {
|
|
237
|
+
if (SNAPSHOT_RE.test(row.subject)) continue;
|
|
238
|
+
if (row.predicate === "rdf:type" || PLACEMENT_PREDICATES.has(row.predicate)) names.add(row.subject);
|
|
239
|
+
if (EXIT_PREDICATE_RE.test(row.predicate)) { names.add(row.subject); names.add(row.object); }
|
|
240
|
+
}
|
|
241
|
+
return [...names].sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
208
244
|
/** Fold fact rows into the CURRENT world state: per subject, the newest
|
|
209
245
|
* placement (base row = turn 0, @turnN snapshots override), the newest
|
|
210
246
|
* open/closed state, the newest mass, the exit map, and the turn counter (the
|
|
@@ -346,6 +382,59 @@ function backgroundOnlyMention(rows, state, object) {
|
|
|
346
382
|
return (rows || []).some((r) => r.subject === object || r.object === object);
|
|
347
383
|
}
|
|
348
384
|
|
|
385
|
+
/** True when `subject` is one of the world's cast rather than a prop: a
|
|
386
|
+
* declared person, or anything the world places with mgx:currently-in — the
|
|
387
|
+
* predicate every world reserves for a character standing in a room, props
|
|
388
|
+
* riding located-in/fixed-in/stands-locked-in instead. Both halves matter:
|
|
389
|
+
* ashcombe-hall types its staff `person`, while mud-garden types its animals
|
|
390
|
+
* `adventurer` and places them the same way, so a person-only test leaves a
|
|
391
|
+
* whole cast with nobody able to speak to it. */
|
|
392
|
+
function isCastMember(rows, state, subject) {
|
|
393
|
+
return isTyped(rows, subject, "person") || state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Who else is standing in `room` right now, sorted — the same currently-in
|
|
397
|
+
* placement the talk verb and the room affordances read, exposed so a caller
|
|
398
|
+
* rendering a room can name its cast without re-deriving the test. Pure. */
|
|
399
|
+
export function castInRoom(rows, state, room, exclude = null) {
|
|
400
|
+
return [...state.placements.keys()]
|
|
401
|
+
.filter((subject) => subject !== exclude && subject !== room)
|
|
402
|
+
.filter((subject) => state.placements.get(subject).object === room)
|
|
403
|
+
.filter((subject) => isCastMember(rows, state, subject))
|
|
404
|
+
.sort();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A predator eats whatever walks into its room. The marker is a world fact,
|
|
408
|
+
// so which individual is dangerous is the world's business, never this
|
|
409
|
+
// module's.
|
|
410
|
+
const PREDATOR_PREDICATE = "mgx:is-predator";
|
|
411
|
+
|
|
412
|
+
/** The predator standing in `room`, or null — read from the same placements
|
|
413
|
+
* fold every other presence check uses. Pure. */
|
|
414
|
+
function predatorIn(rows, state, room) {
|
|
415
|
+
return castInRoom(rows, state, room)
|
|
416
|
+
.find((subject) => factObjects(rows, subject, PREDATOR_PREDICATE).includes("true")) ?? null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** True when `subject` has left the world: placed at an out-of-play sentinel
|
|
420
|
+
* no room can be called. Its part in the world is finished — every command it
|
|
421
|
+
* gives declines, and its scripted turns stop. Pure. */
|
|
422
|
+
export const isOutOfPlay = (state, subject) => OUT_OF_PLAY_PLACES.has(state.placements.get(subject)?.object);
|
|
423
|
+
|
|
424
|
+
/** WHY `subject` is out of play — "eaten" or "starved" — or null while it is
|
|
425
|
+
* still playing. The two fates end a run the same way and read nothing alike,
|
|
426
|
+
* so anything narrating one needs to tell them apart. Pure. */
|
|
427
|
+
export const outOfPlayReasonOf = (state, subject) => {
|
|
428
|
+
const place = state.placements.get(subject)?.object;
|
|
429
|
+
return OUT_OF_PLAY_PLACES.has(place) ? place : null;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** How a fate reads in a sentence: "the mole-1 has been eaten", "the mole-1 has
|
|
433
|
+
* starved". One phrase per sentinel, so nothing anywhere else has to spell the
|
|
434
|
+
* difference out. Pure. */
|
|
435
|
+
export const outOfPlayPhrase = (subject, reason) =>
|
|
436
|
+
(reason === STARVED_PLACE ? `the ${subject} has starved` : `the ${subject} has been eaten`);
|
|
437
|
+
|
|
349
438
|
/** The room's real affordances — every exit, and every visible object's
|
|
350
439
|
* applicable verb — read from the EXACT SAME data take/open/talk/examine
|
|
351
440
|
* already check (visibleRoomOf, isContainer, isTyped, the placement
|
|
@@ -375,7 +464,7 @@ export function roomAffordances(rows, state, here, actingSubject = "player") {
|
|
|
375
464
|
actions.push(`examine ${subject}`);
|
|
376
465
|
continue;
|
|
377
466
|
}
|
|
378
|
-
if (
|
|
467
|
+
if (isCastMember(rows, state, subject)) {
|
|
379
468
|
actions.push(`talk to ${subject}`);
|
|
380
469
|
continue;
|
|
381
470
|
}
|
|
@@ -465,13 +554,25 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
|
|
|
465
554
|
// trust track record. The side effect is that worldActionRows filters these
|
|
466
555
|
// out of the playable state fold, which is what you want — being told about a
|
|
467
556
|
// stone must never move the stone.
|
|
557
|
+
//
|
|
558
|
+
// A claim can also go out of date, and none of them is ever retracted. Eating
|
|
559
|
+
// the last carrot appends a SECOND claim to the same edge, tagged `:gone`, and
|
|
560
|
+
// appendFacts unions the two tags onto the one fact exactly as it unions any
|
|
561
|
+
// repeat assertion. "The carrot was here on turn 2" and "the carrot is gone on
|
|
562
|
+
// turn 5" are both true; the reader's job is to say which one rules, the same
|
|
563
|
+
// recency question the p2p layer asks of its own tags. So reading knowledge
|
|
564
|
+
// back means reading the newest claim per edge, never the union of every claim
|
|
565
|
+
// ever made.
|
|
468
566
|
|
|
469
|
-
const
|
|
567
|
+
const VOIDED_TESTIMONY_SUFFIX = ":gone";
|
|
470
568
|
|
|
471
|
-
|
|
569
|
+
const characterTestimonyTag = (character, k, voided = false) =>
|
|
570
|
+
`mud:${character}:turn${k}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
|
|
571
|
+
|
|
572
|
+
async function appendTestimony(memoryDir, { knower, source, thing, k, voided = false, cache }) {
|
|
472
573
|
await appendFacts(memoryDir, [{
|
|
473
574
|
subject: knower, predicate: KNOWS_ABOUT_PREDICATE, object: thing,
|
|
474
|
-
provenance: characterTestimonyTag(source, k),
|
|
575
|
+
provenance: characterTestimonyTag(source, k, voided),
|
|
475
576
|
}]);
|
|
476
577
|
if (cache) cache.rows = null;
|
|
477
578
|
}
|
|
@@ -490,6 +591,98 @@ export async function recordExamined(memoryDir, { observer, thing, k, cache = nu
|
|
|
490
591
|
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, cache });
|
|
491
592
|
}
|
|
492
593
|
|
|
594
|
+
/** Record that `observer` saw `thing` leave the world on turn `k` — it ate the
|
|
595
|
+
* last of it. Written as a fresh claim on the SAME edge an older one already
|
|
596
|
+
* sits on, so the older claim stands untouched and stops being the one that
|
|
597
|
+
* rules. The observer is its own source, the way examining is. */
|
|
598
|
+
export async function recordGone(memoryDir, { observer, thing, k, cache = null }) {
|
|
599
|
+
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, voided: true, cache });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const TESTIMONY_TAG_RE = /^mud:([^:\s]+):turn(\d+)(:gone)?$/;
|
|
603
|
+
const TURN_STAMP_RE = /:turn(\d+)\b/;
|
|
604
|
+
|
|
605
|
+
/** How one provenance segment on a knows-about edge stands as a claim about
|
|
606
|
+
* what `knower` knows: whether the knower vouches for it itself, the turn it
|
|
607
|
+
* was asserted on, and whether it says the thing is gone. A tag that is no
|
|
608
|
+
* character's testimony — a world's own seed fact, a dig spawn — reads as
|
|
609
|
+
* hearsay stamped with whatever turn it carries. */
|
|
610
|
+
function testimonyClaim(segment, knower) {
|
|
611
|
+
const mine = TESTIMONY_TAG_RE.exec(segment);
|
|
612
|
+
const stamp = Number(mine ? mine[2] : (TURN_STAMP_RE.exec(segment)?.[1] ?? 0));
|
|
613
|
+
return {
|
|
614
|
+
firsthand: !!mine && mine[1] === knower,
|
|
615
|
+
turn: Number.isFinite(stamp) ? stamp : 0,
|
|
616
|
+
voided: !!(mine && mine[3]),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Firsthand beats hearsay outright, then the later turn wins, then "gone"
|
|
621
|
+
* takes the tie — an animal that examined a carrot and ate it on one turn ate
|
|
622
|
+
* it second. Tier ABOVE recency is what stops an animal being talked back into
|
|
623
|
+
* a meal it ate itself: a room-mate can tell it about that carrot the turn
|
|
624
|
+
* after, and its own eyes still hold. */
|
|
625
|
+
const outranksClaim = (claim, best) => (
|
|
626
|
+
claim.firsthand !== best.firsthand ? claim.firsthand
|
|
627
|
+
: claim.turn !== best.turn ? claim.turn > best.turn
|
|
628
|
+
: claim.voided && !best.voided
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
/** The claim that rules on one knows-about edge, across every segment its
|
|
632
|
+
* provenance carries. */
|
|
633
|
+
function rulingTestimonyClaim(provenance, knower) {
|
|
634
|
+
let best = null;
|
|
635
|
+
for (const segment of String(provenance || "").split(" | ")) {
|
|
636
|
+
const tag = segment.trim();
|
|
637
|
+
if (!tag) continue;
|
|
638
|
+
const claim = testimonyClaim(tag, knower);
|
|
639
|
+
if (!best || outranksClaim(claim, best)) best = claim;
|
|
640
|
+
}
|
|
641
|
+
return best;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** What `person` knows about NOW: the object of every knows-about edge whose
|
|
645
|
+
* ruling claim still stands, in the order the edges were first written.
|
|
646
|
+
* Nothing is deleted — an edge whose newest claim says the thing is gone just
|
|
647
|
+
* stops reading back. */
|
|
648
|
+
function currentKnowsAboutTopics(rows, person) {
|
|
649
|
+
const topics = [];
|
|
650
|
+
for (const row of rows || []) {
|
|
651
|
+
if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
|
|
652
|
+
if (rulingTestimonyClaim(row.provenance, person)?.voided) continue;
|
|
653
|
+
topics.push(row.object);
|
|
654
|
+
}
|
|
655
|
+
return topics;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Charge `subject` the mass a turn costs it, and place it out of play at the
|
|
660
|
+
* starved sentinel once nothing is left. Returns `{ mass, starved }` — the mass
|
|
661
|
+
* it is left with, and whether that ended its run. Writes nothing and charges
|
|
662
|
+
* nothing when the drain is zero, when the subject is already out of play, or
|
|
663
|
+
* when the world gives it no mass at all (`mass` is then null: a thing with no
|
|
664
|
+
* mass cannot run out of it).
|
|
665
|
+
*
|
|
666
|
+
* The write lands on the world's OWN next turn, read fresh here rather than
|
|
667
|
+
* taken from the caller. A scripted turn runs several world commands, each
|
|
668
|
+
* stamping a turn of its own, so a caller's tick number can trail the world's
|
|
669
|
+
* count — and a mass snapshot stamped behind the newest placement would fold
|
|
670
|
+
* away as stale the moment it was written.
|
|
671
|
+
*/
|
|
672
|
+
export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn, cache = null }) {
|
|
673
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
674
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
675
|
+
const mass = state.masses.get(subject)?.value ?? null;
|
|
676
|
+
if (mass === null || !(drainPerTurn > 0) || isOutOfPlay(state, subject)) return { mass, starved: false };
|
|
677
|
+
const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
|
|
678
|
+
const k = state.turnCount + 1;
|
|
679
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
680
|
+
{ subject: `${subject}@turn${k}`, predicate: MASS_PREDICATE, object: String(left) },
|
|
681
|
+
...(left > 0 ? [] : [{ subject: `${subject}@turn${k}`, predicate: "mgx:currently-in", object: STARVED_PLACE }]),
|
|
682
|
+
], cache);
|
|
683
|
+
return { mass: left, starved: left <= 0 };
|
|
684
|
+
}
|
|
685
|
+
|
|
493
686
|
// ---- the look/inventory digest ----------------------------------------------
|
|
494
687
|
//
|
|
495
688
|
// "look" and "what am I carrying" are generateCompletion calls (the shipped
|
|
@@ -523,6 +716,21 @@ const VIEW_EXCLUDED_PREDICATES = new Set([
|
|
|
523
716
|
// subClassOf drive positional rendering by their own readers, and read as
|
|
524
717
|
// raw triples if they land in room prose.
|
|
525
718
|
"mgx:default-contains", "mgx:default-plane", "rdfs:subClassOf",
|
|
719
|
+
// A screen name is presentation, not scenery — it reads as a raw triple in
|
|
720
|
+
// room prose ("Carrot-1 mgx:display-name carrot") and says nothing the
|
|
721
|
+
// room's own sentences don't already say.
|
|
722
|
+
DISPLAY_NAME_PREDICATE,
|
|
723
|
+
// Which individual is dangerous is the predator mechanic's own wiring; a
|
|
724
|
+
// room look that announced it would give the trap away as a bare triple.
|
|
725
|
+
"mgx:is-predator",
|
|
726
|
+
// The dig mechanic's wiring is the same kind of thing: it tells the verb what
|
|
727
|
+
// a dug room may hold and how far the world reaches, and says nothing about
|
|
728
|
+
// the room anyone is standing in.
|
|
729
|
+
ORIGIN_PREDICATE, DIG_SPAWN_PREDICATE, DEN_SPAWN_PREDICATE, DEN_RESIDENT_PREDICATE,
|
|
730
|
+
DIG_REACH_PREDICATE, DIG_SPAWN_MAX_PREDICATE, DEN_CHANCE_PREDICATE, DEN_RESIDENT_CHANCE_PREDICATE,
|
|
731
|
+
// How fast a turn wears a species down is the mass economy's own wiring, and
|
|
732
|
+
// reads as a bare number in room prose the same way hasMass does.
|
|
733
|
+
MASS_DRAIN_PREDICATE,
|
|
526
734
|
]);
|
|
527
735
|
|
|
528
736
|
const sentenceCase = (term) => String(term).charAt(0).toUpperCase() + String(term).slice(1);
|
|
@@ -573,7 +781,7 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
|
|
|
573
781
|
const isCarryingCharacter = (holder) =>
|
|
574
782
|
isTyped(rows, holder, "person") || state.placements.get(holder)?.predicate === "mgx:currently-in";
|
|
575
783
|
for (const [subject, place] of state.placements) {
|
|
576
|
-
if (place.predicate === "mgx:hidden-in" || place.object
|
|
784
|
+
if (place.predicate === "mgx:hidden-in" || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
577
785
|
if (place.predicate === "mgx:located-in" && place.object === actingSubject) {
|
|
578
786
|
push(actingSubject, "carries the", subject);
|
|
579
787
|
continue;
|
|
@@ -596,18 +804,18 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
|
|
|
596
804
|
// already assumes, so it is left unsaid.
|
|
597
805
|
const POSITION_PHRASE = { "mgx:on-top-of": "is on the", "mgx:on-plane": "is on the", "mgx:under": "is under the" };
|
|
598
806
|
for (const [subject, place] of state.placements) {
|
|
599
|
-
if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || place.object
|
|
807
|
+
if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
600
808
|
const pos = currentPosition(state, subject);
|
|
601
809
|
if (pos && POSITION_PHRASE[pos.predicate]) { push(subject, POSITION_PHRASE[pos.predicate], pos.object); continue; }
|
|
602
810
|
const plane = classDefaultPlane(rows, subject);
|
|
603
811
|
if (plane && plane !== "floor") push(subject, "is usually on the", plane);
|
|
604
812
|
}
|
|
605
|
-
const
|
|
606
|
-
.filter(([, place]) => place.object
|
|
813
|
+
const gone = new Set([...state.placements]
|
|
814
|
+
.filter(([, place]) => OUT_OF_PLAY_PLACES.has(place.object))
|
|
607
815
|
.map(([subject]) => subject));
|
|
608
816
|
for (const row of rows || []) {
|
|
609
817
|
if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
|
|
610
|
-
if (
|
|
818
|
+
if (gone.has(row.subject)) continue; // out of the world entirely
|
|
611
819
|
// Room text comes from the world source only. A merged corpus overlaps a
|
|
612
820
|
// room's own vocabulary ("library rdfs:subClassOf literary study"), and
|
|
613
821
|
// without this those rows leak into the room description as stray sentences.
|
|
@@ -704,12 +912,160 @@ const OPPOSITE_DIRECTION = new Map([
|
|
|
704
912
|
["up", "down"], ["down", "up"],
|
|
705
913
|
]);
|
|
706
914
|
|
|
707
|
-
// What a freshly dug room holds
|
|
708
|
-
//
|
|
709
|
-
//
|
|
915
|
+
// What a freshly dug room holds, and how often a dig opens something better
|
|
916
|
+
// than a bare tunnel. The world names all of it — a room kind declares the
|
|
917
|
+
// kinds a plain dig turns up, how many of them, the richer set a den holds, how
|
|
918
|
+
// often a dig finds one, and which animal lives in it. The numbers below are
|
|
919
|
+
// only the fallback for a world that declares none.
|
|
710
920
|
const DIG_SPAWN_KINDS = ["root", "carrot", "worm"];
|
|
711
921
|
const DIG_SPAWN_MIN = 0;
|
|
712
|
-
const
|
|
922
|
+
const DEFAULT_DIG_SPAWN_MAX = 2;
|
|
923
|
+
const DEFAULT_DEN_CHANCE_IN = 5;
|
|
924
|
+
const DEFAULT_DEN_RESIDENT_CHANCE_IN = 3;
|
|
925
|
+
const DEN_ROOM_CLASS = "den";
|
|
926
|
+
|
|
927
|
+
// How far from the world's origin room a dig may carry it, when the origin
|
|
928
|
+
// writes no reach of its own. Without a cap a burrow sprawls in every direction
|
|
929
|
+
// at once, and an animal twenty hops out has nothing around it, no food it
|
|
930
|
+
// knows of, and no reason to be anywhere — the stranding this bound exists to
|
|
931
|
+
// stop. Six keeps every room inside one pathfinder search of the origin
|
|
932
|
+
// (mud-turn.mjs walks eight hops), so an animal standing at the frontier can
|
|
933
|
+
// always still walk home to the rooms with food in them.
|
|
934
|
+
const DEFAULT_DIG_REACH = 6;
|
|
935
|
+
|
|
936
|
+
/** The NEWEST value `subject` declares under `predicate`, as a number, or null
|
|
937
|
+
* when it declares none. Newest rather than first on purpose: the store is
|
|
938
|
+
* append-only, so a later write is the current truth — the same rule
|
|
939
|
+
* foldWorldState already applies to placements, and what lets an edit to one of
|
|
940
|
+
* these knobs take effect over the world's own seed fact. Pure. */
|
|
941
|
+
function declaredNumber(rows, subject, predicate) {
|
|
942
|
+
const written = factObjects(rows, subject, predicate);
|
|
943
|
+
if (!written.length) return null;
|
|
944
|
+
const value = Number(written[written.length - 1]);
|
|
945
|
+
return Number.isFinite(value) ? value : null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** A positive count `subject` declares under `predicate`, or `fallback` when it
|
|
949
|
+
* declares none (or writes something that is not a usable count). Pure. */
|
|
950
|
+
function declaredCountOr(rows, subject, predicate, fallback) {
|
|
951
|
+
const written = declaredNumber(rows, subject, predicate);
|
|
952
|
+
return written !== null && written > 0 ? written : fallback;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** How many rooms out from its origin this world lets a dig reach — the origin
|
|
956
|
+
* room's own `mgx:dig-reach` fact, or the shipped default when it writes none.
|
|
957
|
+
* Pure. */
|
|
958
|
+
export function digReachOf(rows) {
|
|
959
|
+
const origin = originRoomOf(rows);
|
|
960
|
+
return origin ? declaredCountOr(rows, origin, DIG_REACH_PREDICATE, DEFAULT_DIG_REACH) : DEFAULT_DIG_REACH;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** What one turn costs `subject` in mass, from a `mgx:mass-drain-per-turn` fact
|
|
964
|
+
* on its own class chain (so a whole species is tuned in one line, and one
|
|
965
|
+
* individual can still overrule its species by writing its own). Null when
|
|
966
|
+
* nothing in the chain declares one — a knob nobody set is not a reason to
|
|
967
|
+
* invent a number and starve something with it. Pure. */
|
|
968
|
+
export function massDrainPerTurnOf(rows, subject) {
|
|
969
|
+
for (const kind of objectClassChain(rows, subject)) {
|
|
970
|
+
const written = declaredNumber(rows, kind, MASS_DRAIN_PREDICATE);
|
|
971
|
+
if (written !== null && written >= 0) return written;
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Which way a room of each kind can be dug, and what the room it opens is
|
|
977
|
+
// typed as. Above ground there is nothing to tunnel sideways through, so the
|
|
978
|
+
// only dig is straight down into the soil; below ground the burrow spreads
|
|
979
|
+
// across its own level and can surface again. Digging deeper is left out so
|
|
980
|
+
// the burrow stays the one level the soil cross-section draws.
|
|
981
|
+
const DIGGABLE_BY_ROOM_KIND = new Map([
|
|
982
|
+
["outdoor", new Map([["down", "underground-space"]])],
|
|
983
|
+
["underground", new Map([
|
|
984
|
+
["north", "underground-space"],
|
|
985
|
+
["south", "underground-space"],
|
|
986
|
+
["east", "underground-space"],
|
|
987
|
+
["west", "underground-space"],
|
|
988
|
+
["up", "outdoor-space"],
|
|
989
|
+
])],
|
|
990
|
+
["indoor", new Map()],
|
|
991
|
+
]);
|
|
992
|
+
|
|
993
|
+
const DIG_DECLINE_BY_ROOM_KIND = {
|
|
994
|
+
outdoor: (room, direction) => (direction === "up"
|
|
995
|
+
? `there's nothing but sky above the ${room}.`
|
|
996
|
+
: `you can't tunnel ${direction} out here — the ${room} is open ground, not soil to dig through. Dig down to get under it.`),
|
|
997
|
+
underground: (room) => `the earth below the ${room} is packed solid — this burrow runs one level deep.`,
|
|
998
|
+
indoor: (room, direction) => `you can't dig ${direction} out of the ${room}.`,
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/** A room's own kind, from the rdf:type facts the world writes about it:
|
|
1002
|
+
* "outdoor" (the surface), "underground" (the burrow), or "indoor" for a
|
|
1003
|
+
* walled room that says neither. Pure. */
|
|
1004
|
+
export function roomKindOf(rows, room) {
|
|
1005
|
+
const typedAs = (kind) => (rows || []).some((r) => r.subject === room && r.predicate === "rdf:type" && r.object === kind);
|
|
1006
|
+
if (typedAs("outdoor-space")) return "outdoor";
|
|
1007
|
+
if (typedAs("underground-space")) return "underground";
|
|
1008
|
+
return "indoor";
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** The room a world calls its origin — the one every dig is measured from — or
|
|
1012
|
+
* null when it names none. A world with no origin fact is simply not bounded.
|
|
1013
|
+
* Pure. */
|
|
1014
|
+
export function originRoomOf(rows) {
|
|
1015
|
+
return (rows || []).find((r) => r.predicate === ORIGIN_PREDICATE && r.object === "true")?.subject ?? null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** How many exits a walk from the world's origin to `room` crosses, or null
|
|
1019
|
+
* when the world declares no origin or no chain of exits joins the two. Pure. */
|
|
1020
|
+
export function roomDistanceFromOrigin(rows, state, room) {
|
|
1021
|
+
const origin = originRoomOf(rows);
|
|
1022
|
+
if (!origin) return null;
|
|
1023
|
+
if (origin === room) return 0;
|
|
1024
|
+
const seen = new Set([origin]);
|
|
1025
|
+
let frontier = [origin];
|
|
1026
|
+
for (let distance = 1; frontier.length; distance += 1) {
|
|
1027
|
+
const next = [];
|
|
1028
|
+
for (const from of frontier) {
|
|
1029
|
+
for (const target of state.exits.get(from)?.values() ?? []) {
|
|
1030
|
+
if (seen.has(target)) continue;
|
|
1031
|
+
seen.add(target);
|
|
1032
|
+
if (target === room) return distance;
|
|
1033
|
+
next.push(target);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
frontier = next;
|
|
1037
|
+
}
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** True when `room` is as far from the origin as this world digs, or off the
|
|
1042
|
+
* origin's map altogether. A freshly dug room's only other exit is the one
|
|
1043
|
+
* back, so its distance is always this room's plus one — which makes the whole
|
|
1044
|
+
* boundary test a property of where the digger stands, never of the direction
|
|
1045
|
+
* it faces.
|
|
1046
|
+
*
|
|
1047
|
+
* A room the origin cannot reach is the strictest case, not the loosest: it
|
|
1048
|
+
* has no measurable distance, so nothing would ever stop it growing, and a
|
|
1049
|
+
* burrow with no way home is precisely what the bound exists to prevent. A
|
|
1050
|
+
* world that declares no origin at all is a different thing and stays
|
|
1051
|
+
* unbounded. Pure. */
|
|
1052
|
+
function atDigBoundary(rows, state, room) {
|
|
1053
|
+
if (!originRoomOf(rows)) return false;
|
|
1054
|
+
const distance = roomDistanceFromOrigin(rows, state, room);
|
|
1055
|
+
return distance === null || distance >= digReachOf(rows);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Every direction a dig could actually open a room in from `room`: allowed
|
|
1059
|
+
* by the room's own kind, with no exit already written that way, and inside
|
|
1060
|
+
* the world's dig boundary. This is the exact set the dig verb accepts, so a
|
|
1061
|
+
* caller offering these as hints can never suggest a dig the verb would then
|
|
1062
|
+
* refuse. Pure. */
|
|
1063
|
+
export function diggableDirections(rows, state, room) {
|
|
1064
|
+
if (atDigBoundary(rows, state, room)) return [];
|
|
1065
|
+
const exits = state.exits.get(room);
|
|
1066
|
+
return [...(DIGGABLE_BY_ROOM_KIND.get(roomKindOf(rows, room)) ?? new Map()).keys()]
|
|
1067
|
+
.filter((direction) => !exits?.has(direction));
|
|
1068
|
+
}
|
|
713
1069
|
|
|
714
1070
|
const FOOD_CLASS = "food";
|
|
715
1071
|
// A shared reference mass standing in for per-species maxima until the game
|
|
@@ -741,6 +1097,55 @@ function freshRoomId(rows, here, direction) {
|
|
|
741
1097
|
return `${base}-${(rows || []).length + 3}`;
|
|
742
1098
|
}
|
|
743
1099
|
|
|
1100
|
+
/** An unused id for a freshly dug object, reading as its plain kind and a
|
|
1101
|
+
* small number ("carrot-1"). The short id is what keeps a pouch readable:
|
|
1102
|
+
* naming a spawned object after the room it came out of inherits that room's
|
|
1103
|
+
* whole nested dig path ("carrot-sett-1-north-east-east"), which is an id, not
|
|
1104
|
+
* a name anyone can read. `alsoTaken` holds the ids minted earlier in this
|
|
1105
|
+
* same dig, which are not in `rows` yet. Pure. */
|
|
1106
|
+
function freshObjectId(rows, kind, alsoTaken) {
|
|
1107
|
+
const taken = (id) => alsoTaken.has(id) || (rows || []).some((r) => r.subject === id || r.object === id);
|
|
1108
|
+
for (let n = 1; n <= (rows || []).length + 2; n += 1) {
|
|
1109
|
+
if (!taken(`${kind}-${n}`)) return `${kind}-${n}`;
|
|
1110
|
+
}
|
|
1111
|
+
return `${kind}-${(rows || []).length + 3}`;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** The kinds a room kind declares for one of the spawn pools, in the order the
|
|
1115
|
+
* world wrote them, or `fallback` when it declares none. Pure. */
|
|
1116
|
+
function declaredKindsOr(rows, roomClass, predicate, fallback) {
|
|
1117
|
+
const declared = factObjects(rows, roomClass, predicate);
|
|
1118
|
+
return declared.length ? declared : fallback;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** The mass row a freshly minted instance needs, copied off its own class, or
|
|
1122
|
+
* nothing when the class declares no mass. eat reads the instance's mass, so a
|
|
1123
|
+
* dug carrot with none would be worth the flat default however the world
|
|
1124
|
+
* values a carrot. Pure. */
|
|
1125
|
+
function classMassFacts(rows, instance, kind) {
|
|
1126
|
+
const mass = factObjects(rows, kind, MASS_PREDICATE)[0];
|
|
1127
|
+
return mass ? [{ subject: instance, predicate: MASS_PREDICATE, object: mass }] : [];
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** What a dig reads like: a bare tunnel, a scrap or two in the loose earth, or
|
|
1131
|
+
* a den — and, when somebody lives in it, who looked up. Pure. */
|
|
1132
|
+
function digNarration(direction, { isDen, spawned, resident }) {
|
|
1133
|
+
const opened = isDen
|
|
1134
|
+
? `you dig ${direction} and break into a den somebody hollowed out.`
|
|
1135
|
+
: `you dig ${direction} and open up a new room.`;
|
|
1136
|
+
const held = spawned.length
|
|
1137
|
+
? ` ${isDen ? "Stored in it" : "In the loose earth"}: the ${spawned.join(", the ")}.`
|
|
1138
|
+
: " There's nothing in it but bare earth.";
|
|
1139
|
+
return `${opened}${held}${resident ? ` The ${resident} lives here, and looks up as you come through.` : ""}`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** What a thing should be CALLED on screen: its declared display name, else
|
|
1143
|
+
* its own id. A dug object carries one so a pouch can list "carrot" while the
|
|
1144
|
+
* world keeps the distinct id ("carrot-1") every verb resolves against. Pure. */
|
|
1145
|
+
export function displayNameOf(rows, subject) {
|
|
1146
|
+
return factObjects(rows, subject, DISPLAY_NAME_PREDICATE)[0] ?? subject;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
744
1149
|
/** A container's open/locked status, stated plainly, and (only once already
|
|
745
1150
|
* open) its visible contents — the one thing examine/talk's reused
|
|
746
1151
|
* worldDigest call never states on its own, since mgx:is-open is a
|
|
@@ -783,7 +1188,8 @@ function containerDatatypeState(state, object) {
|
|
|
783
1188
|
* thing's current location, a hidden one included: talking to the staff is
|
|
784
1189
|
* the sanctioned way to learn a hiding place, while the where-is aside keeps
|
|
785
1190
|
* declining. `knows-objective` states the quest. `knows-about` topics come
|
|
786
|
-
* back for the caller to digest
|
|
1191
|
+
* back for the caller to digest, each one read from its newest surviving
|
|
1192
|
+
* claim. Pure. */
|
|
787
1193
|
export function personKnowledgeLines(rows, state, person) {
|
|
788
1194
|
const lines = [];
|
|
789
1195
|
for (const objective of factObjects(rows, person, "mgx:knows-objective")) {
|
|
@@ -796,7 +1202,7 @@ export function personKnowledgeLines(rows, state, person) {
|
|
|
796
1202
|
? `you'll find the ${thing} in the ${place.object}.`
|
|
797
1203
|
: `the ${thing} is in the ${place.object}.`);
|
|
798
1204
|
}
|
|
799
|
-
return { lines, aboutTopics:
|
|
1205
|
+
return { lines, aboutTopics: currentKnowsAboutTopics(rows, person) };
|
|
800
1206
|
}
|
|
801
1207
|
|
|
802
1208
|
/** The FOOD_CLASS things `person` durably knows about — from being told, or
|
|
@@ -807,7 +1213,7 @@ export function personKnowledgeLines(rows, state, person) {
|
|
|
807
1213
|
* food query has no per-topic sub-digest to hand back, so this returns the
|
|
808
1214
|
* plain list of known food things rather than a {lines, topics} pair. Pure. */
|
|
809
1215
|
export function personKnownFoodLines(rows, state, person) {
|
|
810
|
-
return
|
|
1216
|
+
return currentKnowsAboutTopics(rows, person)
|
|
811
1217
|
.filter((thing) => objectClassChain(rows, thing).includes(FOOD_CLASS));
|
|
812
1218
|
}
|
|
813
1219
|
|
|
@@ -854,6 +1260,13 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
854
1260
|
{ miss: true },
|
|
855
1261
|
);
|
|
856
1262
|
}
|
|
1263
|
+
if (OUT_OF_PLAY_PLACES.has(here)) {
|
|
1264
|
+
return answer(
|
|
1265
|
+
`${outOfPlayPhrase(actingSubject, here)} — it takes no more turns in this world.`,
|
|
1266
|
+
noteFor(`${cmd.verb} — ${actingSubject} is placed out of play (${here}); every command it gives declines from here on`),
|
|
1267
|
+
{ miss: true },
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
857
1270
|
|
|
858
1271
|
if (cmd.verb === "look" && !cmd.object) {
|
|
859
1272
|
const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
@@ -903,7 +1316,7 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
903
1316
|
{ miss: true },
|
|
904
1317
|
);
|
|
905
1318
|
}
|
|
906
|
-
const person =
|
|
1319
|
+
const person = isCastMember(rows, state, object);
|
|
907
1320
|
// "look <object>" on a real placed prop is the grounded close look: every
|
|
908
1321
|
// physical fact the world writes about the thing (its placement, its
|
|
909
1322
|
// within-room position, any datatype property — all via the SAME
|
|
@@ -1006,6 +1419,21 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
1006
1419
|
{ miss: true },
|
|
1007
1420
|
);
|
|
1008
1421
|
}
|
|
1422
|
+
// A predator eats whatever walks in, and the room it guards is the one
|
|
1423
|
+
// room a move never comes back from — so this write bypasses commit()
|
|
1424
|
+
// entirely: the auto-relook there would describe a room the mover is no
|
|
1425
|
+
// longer standing in, and the world has nobody left to look with.
|
|
1426
|
+
const predator = predatorIn(rows, state, target);
|
|
1427
|
+
if (predator) {
|
|
1428
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
1429
|
+
{ subject: `${actingSubject}@turn${k}`, predicate: "mgx:currently-in", object: CONSUMED_PLACE },
|
|
1430
|
+
], cache);
|
|
1431
|
+
return answer(
|
|
1432
|
+
`you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
|
|
1433
|
+
noteFor(`go — the ${target} holds the predator ${predator}; ${actingSubject} is placed out of play at turn ${k} and takes no further turns`),
|
|
1434
|
+
{ goal: `move through the world (eaten by the ${predator} in the ${target})` },
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1009
1437
|
return commit(
|
|
1010
1438
|
[{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
|
|
1011
1439
|
`you go ${cmd.direction}. Now in the ${target}.`,
|
|
@@ -1063,8 +1491,8 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
1063
1491
|
);
|
|
1064
1492
|
}
|
|
1065
1493
|
const receiver = cmd.indirectObject;
|
|
1066
|
-
if (!
|
|
1067
|
-
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't
|
|
1494
|
+
if (!isCastMember(rows, state, receiver) || state.placements.get(receiver)?.object !== here) {
|
|
1495
|
+
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 });
|
|
1068
1496
|
}
|
|
1069
1497
|
return commit(
|
|
1070
1498
|
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
|
|
@@ -1091,29 +1519,75 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
1091
1519
|
{ miss: true },
|
|
1092
1520
|
);
|
|
1093
1521
|
}
|
|
1522
|
+
const roomKind = roomKindOf(rows, here);
|
|
1523
|
+
const dugKind = (DIGGABLE_BY_ROOM_KIND.get(roomKind) ?? new Map()).get(direction) ?? null;
|
|
1524
|
+
if (!dugKind) {
|
|
1525
|
+
return answer(
|
|
1526
|
+
DIG_DECLINE_BY_ROOM_KIND[roomKind](here, direction),
|
|
1527
|
+
noteFor(`dig — the ${here} is an ${roomKind} room, which cannot be dug ${direction}; declined by the room's own kind`),
|
|
1528
|
+
{ miss: true },
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
if (atDigBoundary(rows, state, here)) {
|
|
1532
|
+
const reach = roomDistanceFromOrigin(rows, state, here);
|
|
1533
|
+
return answer(
|
|
1534
|
+
`the earth ${direction} of the ${here} is packed hard and endless — you have reached the far edge of the burrow.`,
|
|
1535
|
+
noteFor(reach === null
|
|
1536
|
+
? `dig — no chain of exits joins the ${here} to the ${originRoomOf(rows)}, so there is no distance to measure a dig against; declined`
|
|
1537
|
+
: `dig — the ${here} stands ${reach} rooms from the ${originRoomOf(rows)}, and this world digs ${digReachOf(rows)}; declined by distance from the origin`),
|
|
1538
|
+
{ miss: true },
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1094
1541
|
const dug = freshRoomId(rows, here, direction);
|
|
1095
|
-
const
|
|
1096
|
-
const
|
|
1097
|
-
const
|
|
1542
|
+
const denChanceIn = declaredCountOr(rows, dugKind, DEN_CHANCE_PREDICATE, DEFAULT_DEN_CHANCE_IN);
|
|
1543
|
+
const isDen = dugKind === "underground-space" && stableIndex(`den:${dug}`, denChanceIn) === 0;
|
|
1544
|
+
const spawnMax = declaredCountOr(rows, dugKind, DIG_SPAWN_MAX_PREDICATE, DEFAULT_DIG_SPAWN_MAX);
|
|
1545
|
+
const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
|
|
1546
|
+
const spawnedKinds = isDen
|
|
1547
|
+
? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
|
|
1548
|
+
: declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
|
|
1549
|
+
const minted = new Set();
|
|
1550
|
+
const spawned = spawnedKinds.map((kind) => {
|
|
1551
|
+
const id = freshObjectId(rows, kind, minted);
|
|
1552
|
+
minted.add(id);
|
|
1553
|
+
return id;
|
|
1554
|
+
});
|
|
1555
|
+
const residentChanceIn = declaredCountOr(rows, dugKind, DEN_RESIDENT_CHANCE_PREDICATE, DEFAULT_DEN_RESIDENT_CHANCE_IN);
|
|
1556
|
+
const residentKind = isDen && stableIndex(`resident:${dug}`, residentChanceIn) === 0
|
|
1557
|
+
? factObjects(rows, dugKind, DEN_RESIDENT_PREDICATE)[0] ?? null
|
|
1558
|
+
: null;
|
|
1559
|
+
const resident = residentKind ? freshObjectId(rows, residentKind, minted) : null;
|
|
1098
1560
|
return commit(
|
|
1099
1561
|
[
|
|
1100
1562
|
{ subject: dug, predicate: "rdf:type", object: "room" },
|
|
1563
|
+
{ subject: dug, predicate: "rdf:type", object: dugKind },
|
|
1564
|
+
...(isDen ? [{ subject: dug, predicate: "rdf:type", object: DEN_ROOM_CLASS }] : []),
|
|
1101
1565
|
{ subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
|
|
1102
1566
|
{ subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
|
|
1103
1567
|
// Typed to its OWN kind, not a flat "portable" — a spawned kind the
|
|
1104
|
-
// world
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1568
|
+
// world declares rdfs:subClassOf food needs its real class reachable
|
|
1569
|
+
// here for isFood's own objectClassChain walk, or digging up "carrot-1"
|
|
1570
|
+
// would still read as inedible scenery. The class's own mass copies
|
|
1571
|
+
// onto the instance for the same reason: eat reads the instance.
|
|
1108
1572
|
...spawnedKinds.flatMap((kind, i) => ([
|
|
1109
1573
|
{ subject: spawned[i], predicate: "rdf:type", object: kind },
|
|
1574
|
+
{ subject: spawned[i], predicate: DISPLAY_NAME_PREDICATE, object: kind },
|
|
1110
1575
|
{ subject: spawned[i], predicate: "mgx:located-in", object: dug },
|
|
1576
|
+
...classMassFacts(rows, spawned[i], kind),
|
|
1111
1577
|
])),
|
|
1578
|
+
// A resident is placed with currently-in, the predicate that makes an
|
|
1579
|
+
// individual one of the cast, and knows about what its own den holds —
|
|
1580
|
+
// so an animal that digs one out has somebody new to ask about food.
|
|
1581
|
+
...(resident ? [
|
|
1582
|
+
{ subject: resident, predicate: "rdf:type", object: residentKind },
|
|
1583
|
+
{ subject: resident, predicate: DISPLAY_NAME_PREDICATE, object: residentKind },
|
|
1584
|
+
{ subject: resident, predicate: "mgx:currently-in", object: dug },
|
|
1585
|
+
...classMassFacts(rows, resident, residentKind),
|
|
1586
|
+
...spawned.map((thing) => ({ subject: resident, predicate: KNOWS_ABOUT_PREDICATE, object: thing })),
|
|
1587
|
+
] : []),
|
|
1112
1588
|
],
|
|
1113
|
-
spawned
|
|
1114
|
-
|
|
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}`,
|
|
1589
|
+
digNarration(direction, { isDen, spawned, resident }),
|
|
1590
|
+
`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}`,
|
|
1117
1591
|
`dig ${direction} out of the ${here}`,
|
|
1118
1592
|
);
|
|
1119
1593
|
}
|
|
@@ -1144,6 +1618,11 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
|
|
|
1144
1618
|
}
|
|
1145
1619
|
const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
|
|
1146
1620
|
const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
|
|
1621
|
+
// Eating is the one act that ends a thing, so the eater is the one witness
|
|
1622
|
+
// whose knowledge of it goes out of date on the spot. Every route into the
|
|
1623
|
+
// eat verb — a typed command, a scripted mud turn — passes here, so the
|
|
1624
|
+
// claim gets written once for all of them.
|
|
1625
|
+
await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
|
|
1147
1626
|
return commit(
|
|
1148
1627
|
[
|
|
1149
1628
|
{ subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
|
|
@@ -1338,10 +1817,10 @@ async function worldWhereAnswer(line, { memoryDir, actingSubject = "player" }) {
|
|
|
1338
1817
|
{ miss: true, goal: `locate the ${thing}` },
|
|
1339
1818
|
);
|
|
1340
1819
|
}
|
|
1341
|
-
if (place.object
|
|
1820
|
+
if (OUT_OF_PLAY_PLACES.has(place.object)) {
|
|
1342
1821
|
return answer(
|
|
1343
|
-
|
|
1344
|
-
`ADVENTURE — where-aside: ${thing}
|
|
1822
|
+
`${outOfPlayPhrase(thing, place.object)} — it's gone from the world.`,
|
|
1823
|
+
`ADVENTURE — where-aside: ${thing} is out of play (${place.object}), so it has no place left to name`,
|
|
1345
1824
|
{ goal: `locate the ${thing}` },
|
|
1346
1825
|
);
|
|
1347
1826
|
}
|
|
@@ -1396,11 +1875,28 @@ async function worldOpennessAnswer(line, { memoryDir }) {
|
|
|
1396
1875
|
const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
|
|
1397
1876
|
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
1877
|
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;
|
|
1878
|
+
// "who is here" — the room's cast, the question a shared world invites the
|
|
1879
|
+
// moment a second animal walks in. Answered from the same currently-in
|
|
1880
|
+
// placements the talk verb resolves against, so who is named is exactly who
|
|
1881
|
+
// can be talked to.
|
|
1882
|
+
const WORLD_WHO_HERE_RE =
|
|
1883
|
+
/^(?: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
1884
|
// "what food do you know about" and its natural variants — the asking
|
|
1400
1885
|
// character's OWN durable food knowledge (personKnownFoodLines), never the
|
|
1401
|
-
// whole world's food.
|
|
1402
|
-
// about
|
|
1403
|
-
|
|
1886
|
+
// whole world's food. The trailing "about" is optional, "know" swaps for
|
|
1887
|
+
// "found"/"seen"/"heard about", and the "what do you know about food"
|
|
1888
|
+
// inversion and a plain yes/no lead-in both count: every one of these is the
|
|
1889
|
+
// same question, and a phrasing this lane doesn't recognise leaves the world
|
|
1890
|
+
// entirely and comes back answered as vocabulary.
|
|
1891
|
+
const WORLD_KNOWN_FOOD_RE = new RegExp(
|
|
1892
|
+
"^(?:"
|
|
1893
|
+
+ "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)"
|
|
1894
|
+
+ "|what\\s+do\\s+you\\s+know\\s+about\\s+(?:any\\s+)?foods?"
|
|
1895
|
+
+ "|do\\s+you\\s+know\\s+(?:about|of)\\s+(?:any\\s+)?foods?"
|
|
1896
|
+
+ "|where\\s+is\\s+(?:the\\s+)?food"
|
|
1897
|
+
+ ")[?.!\\s]*$",
|
|
1898
|
+
"i",
|
|
1899
|
+
);
|
|
1404
1900
|
|
|
1405
1901
|
/** The in-game orientation asides, answered from the world fold: the player's
|
|
1406
1902
|
* room, the room's real affordances, and the world's objective. Null when the
|
|
@@ -1410,12 +1906,24 @@ async function worldContextAnswer(line, { memoryDir, actingSubject = "player" })
|
|
|
1410
1906
|
const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
|
|
1411
1907
|
const asksOptions = WORLD_OPTIONS_RE.test(l);
|
|
1412
1908
|
const asksQuest = WORLD_QUEST_RE.test(l);
|
|
1413
|
-
|
|
1909
|
+
const asksWhoIsHere = WORLD_WHO_HERE_RE.test(l);
|
|
1910
|
+
if (!asksWhere && !asksOptions && !asksQuest && !asksWhoIsHere) return null;
|
|
1414
1911
|
let rows;
|
|
1415
1912
|
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
1416
1913
|
const state = foldWorldState(worldActionRows(rows));
|
|
1417
1914
|
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
1418
1915
|
|
|
1916
|
+
if (asksWhoIsHere) {
|
|
1917
|
+
const cast = here ? castInRoom(rows, state, here, actingSubject) : [];
|
|
1918
|
+
return answer(
|
|
1919
|
+
cast.length
|
|
1920
|
+
? `here with you in the ${here}: the ${cast.join(", the ")}. You can talk to ${cast.length > 1 ? "any of them" : `the ${cast[0]}`}.`
|
|
1921
|
+
: `nobody else is${here ? ` in the ${here}` : " here"} right now.`,
|
|
1922
|
+
`ADVENTURE — who-is-here aside: the ${here}'s cast from the current placements fold, the same set the talk verb resolves against`,
|
|
1923
|
+
{ goal: "see who else is here" },
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1419
1927
|
if (asksWhere) {
|
|
1420
1928
|
return here
|
|
1421
1929
|
? 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 +1981,39 @@ async function worldKnownFoodAnswer(line, { memoryDir, actingSubject = "player"
|
|
|
1473
1981
|
);
|
|
1474
1982
|
}
|
|
1475
1983
|
|
|
1984
|
+
// A question that names one of the world's OWN minted ids — "sett-1",
|
|
1985
|
+
// "groundhog-1", "carrot-2" — can only be about this world: nothing else in
|
|
1986
|
+
// the session has ever heard that token. So when no world shape matched it,
|
|
1987
|
+
// the fall-through is a plain misroute, and in a session with no code graph it
|
|
1988
|
+
// comes back as the code-graph wall, which says nothing true about a burrow.
|
|
1989
|
+
// The gate is the hyphen: a world id that is a plain dictionary word ("lamp",
|
|
1990
|
+
// "garden") stays out of this, so an ordinary mid-game question about an
|
|
1991
|
+
// ordinary word keeps the lane it has always had.
|
|
1992
|
+
const WORLD_QUESTION_LEAD_RE =
|
|
1993
|
+
/^(?:who|what|where|which|how|why|when|tell\s+me|describe|do\s+you|does|is|are|can\s+you|any)\b/i;
|
|
1994
|
+
const WORLD_MINTED_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
1995
|
+
|
|
1996
|
+
/** A digest about the world-minted id a question names, or null when the line
|
|
1997
|
+
* is not a question, names none, or the world places nothing by that name. */
|
|
1998
|
+
async function worldMentionAnswer(line, { memoryDir, graph, actingSubject = "player" }) {
|
|
1999
|
+
const l = String(line).trim();
|
|
2000
|
+
if (!/\?\s*$/.test(l) && !WORLD_QUESTION_LEAD_RE.test(l)) return null;
|
|
2001
|
+
const spoken = new Set(l.toLowerCase().replace(/[?.!,;:"']/g, " ").split(/\s+/).filter(Boolean));
|
|
2002
|
+
let memory;
|
|
2003
|
+
try { memory = await loadMemory(memoryDir); } catch { return null; }
|
|
2004
|
+
const rows = readFactRows(memory);
|
|
2005
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2006
|
+
const named = worldIndividualNames(rows)
|
|
2007
|
+
.find((subject) => WORLD_MINTED_ID_RE.test(subject) && spoken.has(subject));
|
|
2008
|
+
if (!named) return null;
|
|
2009
|
+
const digest = await worldDigest(named, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
2010
|
+
return answer(
|
|
2011
|
+
digest ?? `nothing more about the ${named} is written down yet.`,
|
|
2012
|
+
`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`,
|
|
2013
|
+
{ goal: `find out about the ${named}` },
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1476
2017
|
async function inventoryAnswer({ memoryDir, graph, actingSubject = "player" }) {
|
|
1477
2018
|
const memory = await loadMemory(memoryDir);
|
|
1478
2019
|
const rows = readFactRows(memory);
|
|
@@ -1577,6 +2118,78 @@ async function bindPronouns(cmd, { discourseHolder, memoryDir, actingSubject = "
|
|
|
1577
2118
|
return { cmd: bound };
|
|
1578
2119
|
}
|
|
1579
2120
|
|
|
2121
|
+
// ---- the world's own vocabulary ----------------------------------------------
|
|
2122
|
+
//
|
|
2123
|
+
// A world's minted ids are words only that world knows. "groundhog-1" is in no
|
|
2124
|
+
// dictionary, so the parser's lexicon gate rejects "talk to groundhog-1" as an
|
|
2125
|
+
// undeclared word and the whole command dies before the talk verb ever sees
|
|
2126
|
+
// it. Declaring those ids as PROPER NAMES for the duration of a world command
|
|
2127
|
+
// fixes that: a proper name outranks every other category, so the id resolves
|
|
2128
|
+
// as itself. Ids the core lexicon already knows are left out, so no ordinary
|
|
2129
|
+
// word changes category because a world happens to use it — and the extension
|
|
2130
|
+
// is scoped to this lane, so the teach and ask lanes keep the plain lexicon.
|
|
2131
|
+
|
|
2132
|
+
let worldLexiconCache = { key: null, base: null, lexicon: null };
|
|
2133
|
+
|
|
2134
|
+
function worldLexicon(rows, base) {
|
|
2135
|
+
const names = worldIndividualNames(rows).filter((name) => !classify(name, base));
|
|
2136
|
+
const key = names.join("");
|
|
2137
|
+
if (worldLexiconCache.base === base && worldLexiconCache.key === key) return worldLexiconCache.lexicon;
|
|
2138
|
+
const lexicon = withProperNames(base, names);
|
|
2139
|
+
worldLexiconCache = { key, base, lexicon };
|
|
2140
|
+
return lexicon;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function worldAwareLexicon(memoryDir, lexicon) {
|
|
2144
|
+
const base = lexicon ?? loadLexicon();
|
|
2145
|
+
try {
|
|
2146
|
+
return worldLexicon(readFactRows(await loadMemory(memoryDir)), base);
|
|
2147
|
+
} catch {
|
|
2148
|
+
return base;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// ---- the vocative: naming who the line is addressed to ------------------------
|
|
2153
|
+
//
|
|
2154
|
+
// Give a window a character's name and players start using it: "groundhog-1
|
|
2155
|
+
// what do you know about food", "mole-1, dig north". The name is who the line
|
|
2156
|
+
// is addressed to, not part of the question — but it makes the line fit no
|
|
2157
|
+
// world shape at all, so the whole turn leaves this lane and comes back
|
|
2158
|
+
// answered as something else entirely (a code question, in a session with no
|
|
2159
|
+
// code graph). Stripping a vocative that names one of the world's OWN placed
|
|
2160
|
+
// individuals costs one fold read, and only on a line that has already failed
|
|
2161
|
+
// on its own terms.
|
|
2162
|
+
|
|
2163
|
+
const escapeForRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
2164
|
+
|
|
2165
|
+
/** `line` with a leading or trailing vocative naming a placed world
|
|
2166
|
+
* individual removed, or null when it carries none (or when the name is the
|
|
2167
|
+
* whole line, which is a bare mention, not an address). Pure. */
|
|
2168
|
+
export function withoutWorldVocative(line, names) {
|
|
2169
|
+
const l = String(line).trim();
|
|
2170
|
+
for (const name of names) {
|
|
2171
|
+
const escaped = escapeForRegExp(name);
|
|
2172
|
+
const leading = new RegExp(`^${escaped}\\s*[,:;]?\\s+`, "i");
|
|
2173
|
+
if (leading.test(l)) {
|
|
2174
|
+
const rest = l.replace(leading, "").trim();
|
|
2175
|
+
if (rest) return rest;
|
|
2176
|
+
}
|
|
2177
|
+
const trailing = new RegExp(`[\\s,]+${escaped}\\s*([?.!]*)$`, "i");
|
|
2178
|
+
if (trailing.test(l)) {
|
|
2179
|
+
const rest = l.replace(trailing, "$1").trim();
|
|
2180
|
+
if (rest) return rest;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
async function addressedLine(line, { memoryDir }) {
|
|
2187
|
+
let rows;
|
|
2188
|
+
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
2189
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2190
|
+
return withoutWorldVocative(line, [...state.placements.keys()].sort());
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1580
2193
|
// ---- the lane ----------------------------------------------------------------
|
|
1581
2194
|
|
|
1582
2195
|
/**
|
|
@@ -1644,13 +2257,29 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1644
2257
|
note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
|
|
1645
2258
|
};
|
|
1646
2259
|
}
|
|
2260
|
+
const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2261
|
+
if (direct) return direct;
|
|
2262
|
+
const addressed = await addressedLine(line, { memoryDir });
|
|
2263
|
+
if (addressed) {
|
|
2264
|
+
const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2265
|
+
if (readdressed) return readdressed;
|
|
2266
|
+
}
|
|
2267
|
+
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** One line against a LIVE world: inventory, an imperative command, then the
|
|
2271
|
+
* in-game asides. Null when the world has no answer for it, which is what
|
|
2272
|
+
* lets an ordinary mid-game question keep its own lane. Split out from the
|
|
2273
|
+
* lane itself so a line carrying a vocative can be re-offered here once,
|
|
2274
|
+
* stripped, without the two paths ever drifting apart. */
|
|
2275
|
+
async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject }) {
|
|
1647
2276
|
if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph, actingSubject });
|
|
1648
|
-
const parsed = parseImperative(line,
|
|
2277
|
+
const parsed = parseImperative(line, await worldAwareLexicon(memoryDir, lexicon));
|
|
1649
2278
|
if (parsed) {
|
|
1650
2279
|
const bound = await bindPronouns(parsed, { discourseHolder, memoryDir, actingSubject });
|
|
1651
2280
|
if (bound.nudge) return bound.nudge;
|
|
1652
2281
|
const cmd = bound.cmd;
|
|
1653
|
-
const result = await runWorldCommand(cmd, { world
|
|
2282
|
+
const result = await runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject });
|
|
1654
2283
|
// The object a command SUCCESSFULLY named registers as a discourse referent
|
|
1655
2284
|
// a later pronoun binds to — so "look lamp" then "examine it" reads the
|
|
1656
2285
|
// lamp, and "talk to housekeeper" makes "him"/"her" the housekeeper. A miss
|
|
@@ -1682,5 +2311,7 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1682
2311
|
if (contextAside) return contextAside;
|
|
1683
2312
|
const knownFoodAside = await worldKnownFoodAnswer(line, { memoryDir, actingSubject });
|
|
1684
2313
|
if (knownFoodAside) return knownFoodAside;
|
|
2314
|
+
const mentionAside = await worldMentionAnswer(line, { memoryDir, graph, actingSubject });
|
|
2315
|
+
if (mentionAside) return mentionAside;
|
|
1685
2316
|
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
1686
2317
|
}
|