@maka/maka-cli 5.198.0 → 5.199.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.
Files changed (32) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +3 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +25 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +25 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -3
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +3 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +49 -0
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +4 -4
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +3 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +3 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +3 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +3 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +3 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +52 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/factions.js +135 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +24 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/game.js +53 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +165 -14
  23. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +12 -2
  24. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +84 -8
  25. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/beat-echo.js +160 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +25 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +38 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +48 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +235 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +122 -0
  32. package/package.json +1 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * THE SAME BEAT TWICE (aCErAfvEW7G23FWDM, reporter: "The NPC speaking
3
+ * the same thing, this is likely something more pervasive, and not
4
+ * really logically an issue of the scene").
5
+ *
6
+ * They were right that it is pervasive: THERE IS NO DEDUPE OF EMITTED
7
+ * TEXT ANYWHERE IN THIS ENGINE. Not a hash, not a similarity check, not
8
+ * a recently-said buffer. What exists is four things that all stop
9
+ * short of it:
10
+ *
11
+ * - the prompt asks nicely ("Do NOT repeat, rephrase, or re-ask
12
+ * anything you have already said"), and the session that filed this
13
+ * item has an NPC delivering one hook twice in two phrasings;
14
+ * - _spokeThisStimulus allows one speech per stimulus, which does
15
+ * nothing about the NEXT stimulus a second later;
16
+ * - _triedThisStimulus dedupes normalized COMMAND STRINGS, not prose;
17
+ * - Room.crowdNote tells each NPC that others are answering too --
18
+ * and its own test says the behavioural claim is "UNVERIFIABLE BY
19
+ * CONSTRUCTION", because the fan-out is parallel and un-awaited.
20
+ *
21
+ * That last one is the reason this has to be a gate at EMISSION rather
22
+ * than another sentence in a prompt. Two NPCs answering the same
23
+ * stimulus are already in flight when either one's prompt is built;
24
+ * neither can be told what the other is about to say. Only the thing
25
+ * that prints them both can know.
26
+ *
27
+ * THE TWO REPORTED PAIRS, both from 2026-09-15T14-45-01-617Z:
28
+ *
29
+ * 11:05:10 "Tallow keeps wiping the glass, watching Ted's body stir
30
+ * behind the beads, then glances at Kessler."
31
+ * "Tallow keeps polishing the same glass, eyes flicking
32
+ * toward the back room as the beads settle."
33
+ *
34
+ * 10:57:18 "Chapel on Tallow Street. The Drowned Choir -- sang for
35
+ * the dead, then went quiet. I want the quiet explained."
36
+ * 10:57:19 "Chapel on Tallow Street. The Drowned Choir. Answer me
37
+ * one question -- why did the singing stop?"
38
+ *
39
+ * Note what they are NOT: identical. A hash would catch neither. They
40
+ * are the same BEAT rewritten, which is what a model does when it is
41
+ * asked twice in two seconds with the same context, and it is why the
42
+ * test below is written against these exact lines.
43
+ */
44
+ /** How long a line stays echo-able. Beyond this an NPC returning to
45
+ * their glass is a character trait, not a stutter. */
46
+ export const ECHO_WINDOW_MS = 45_000;
47
+ /** How many lines per room to remember. Small deliberately: this is a
48
+ * stutter detector, not a transcript. */
49
+ const ECHO_DEPTH = 12;
50
+ /** Words that carry no identity, so overlap on them means nothing.
51
+ * Deliberately broader than utilities/fuzzy-match.ts's STOPWORDS,
52
+ * which is tuned for matching NAMES rather than prose. */
53
+ const NOISE = new Set([
54
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'on', 'in', 'at', 'to', 'for',
55
+ 'with', 'from', 'by', 'as', 'is', 'are', 'was', 'were', 'be', 'been',
56
+ 'it', 'its', 'he', 'she', 'they', 'them', 'his', 'her', 'their', 'him',
57
+ 'that', 'this', 'these', 'those', 'then', 'than', 'there', 'here',
58
+ 'you', 'your', 'i', 'me', 'my', 'we', 'us', 'not', 'no', 'so', 'up',
59
+ 'out', 'off', 'over', 'into', 'through', 'about', 'like', 'just',
60
+ ]);
61
+ /**
62
+ * Keyed on the ROOM OBJECT, weakly. A room that leaves play takes its
63
+ * echoes with it, and nothing here keeps a scene alive.
64
+ */
65
+ const heard = new WeakMap();
66
+ function contentWords(line) {
67
+ return line
68
+ .toLowerCase()
69
+ .replace(/[^\w\s']/g, ' ')
70
+ .split(/\s+/)
71
+ .filter(w => w.length > 1 && !NOISE.has(w));
72
+ }
73
+ /**
74
+ * CONTAINMENT, not Jaccard, and the difference decides whether this
75
+ * works. Jaccard divides by the UNION, so a long line and a short one
76
+ * saying the same thing score low purely because one has more words --
77
+ * and "Tallow keeps polishing the same glass" scores 0.2 against its
78
+ * own twin. Containment asks the question actually being asked: how
79
+ * much of the SHORTER line is already in the longer one.
80
+ */
81
+ function containment(a, b) {
82
+ if (a.length === 0 || b.length === 0)
83
+ return 0;
84
+ const set = new Set(b);
85
+ let shared = 0;
86
+ const seen = new Set();
87
+ for (const w of a) {
88
+ if (seen.has(w))
89
+ continue;
90
+ seen.add(w);
91
+ if (set.has(w))
92
+ shared += 1;
93
+ }
94
+ return shared / Math.min(new Set(a).size, new Set(b).size);
95
+ }
96
+ /**
97
+ * SAME SPEAKER, SAME OPENING. "Tallow keeps wiping" and "Tallow keeps
98
+ * polishing" share their first two content words, and that is a
99
+ * stronger signal of a stutter than any amount of shared vocabulary
100
+ * later in the sentence -- it is the shape of a model restarting the
101
+ * same thought. Paired with a low overlap bar it catches the Tallow
102
+ * pair, which pure overlap (0.36) would otherwise let through.
103
+ */
104
+ function sameOpening(a, b) {
105
+ if (a.length < 2 || b.length < 2)
106
+ return false;
107
+ return a[0] === b[0] && a[1] === b[1];
108
+ }
109
+ /** Overlap at which two lines by one speaker are the same beat. */
110
+ const SAME_SPEAKER = 0.5;
111
+ /** ...and the lower bar once they also START the same way. */
112
+ const SAME_SPEAKER_OPENING = 0.3;
113
+ /**
114
+ * Two DIFFERENT NPCs delivering one hook. A higher bar on purpose:
115
+ * people in a room legitimately talk about the same thing, and two
116
+ * characters agreeing is a scene rather than a bug. Only near-identical
117
+ * content is a second delivery of the same line.
118
+ */
119
+ const OTHER_SPEAKER = 0.65;
120
+ /**
121
+ * HAS THIS ROOM JUST HEARD THIS?
122
+ *
123
+ * Records the line when it is new, so callers need exactly one call.
124
+ * A line judged an echo is NOT recorded -- it never reached anybody, so
125
+ * it cannot be the thing a third line echoes.
126
+ *
127
+ * @param room the room object the line prints in
128
+ * @param speaker who is saying it
129
+ * @param line the emitted prose, as the player would read it
130
+ * @param now injectable clock; the window is the only time rule
131
+ */
132
+ export function judgeEcho(room, speaker, line, now = Date.now()) {
133
+ if (!room)
134
+ return { echo: false };
135
+ const words = contentWords(line);
136
+ // Too short to judge. "Drek." twice in a row is a person, not a
137
+ // stutter, and there is not enough here to tell them apart.
138
+ if (words.length < 4)
139
+ return { echo: false };
140
+ const log = (heard.get(room) ?? []).filter(e => now - e.at < ECHO_WINDOW_MS);
141
+ for (const prior of log) {
142
+ const overlap = containment(words, prior.words);
143
+ const mine = prior.speaker === speaker;
144
+ const bar = mine
145
+ ? (sameOpening(words, prior.lead) ? SAME_SPEAKER_OPENING : SAME_SPEAKER)
146
+ : OTHER_SPEAKER;
147
+ if (overlap >= bar) {
148
+ heard.set(room, log);
149
+ return { echo: true, of: prior.speaker };
150
+ }
151
+ }
152
+ log.push({ speaker, words, lead: words, at: now });
153
+ heard.set(room, log.slice(-ECHO_DEPTH));
154
+ return { echo: false };
155
+ }
156
+ /** Test seam: forget what a room has heard. */
157
+ export function forgetEchoes(room) {
158
+ heard.delete(room);
159
+ }
160
+ //# sourceMappingURL=beat-echo.js.map
@@ -927,6 +927,31 @@ export function metamagicFor(key) {
927
927
  ...baseFields(row),
928
928
  })).get(key);
929
929
  }
930
+ /**
931
+ * A FACTION ROW, rebuilt as the engine reads it (aCErAfvEW7G23FWDM).
932
+ *
933
+ * Same shape as every other kind: the rows live on maka-cli.com and the
934
+ * engine ships none. An unknown slug answers undefined and every rule
935
+ * in factions.ts treats that as "no opinion" -- a cached catalog from
936
+ * before v20 simply behaves the way the engine did before factions.
937
+ */
938
+ export function factionFor(key) {
939
+ return kindIndex('faction', row => ({
940
+ key: row.slug,
941
+ name: row.name,
942
+ description: row.description,
943
+ sort: row.faction.sort,
944
+ ...(row.faction.parent !== undefined ? { parent: row.faction.parent } : {}),
945
+ ...(row.faction.standing !== undefined ? { standing: row.faction.standing } : {}),
946
+ ...(row.faction.territorial !== undefined ? { territorial: row.faction.territorial } : {}),
947
+ ...(row.faction.discreet !== undefined ? { discreet: row.faction.discreet } : {}),
948
+ page: row.page ?? '',
949
+ })).get(key);
950
+ }
951
+ /** Every faction the resolved catalog knows. */
952
+ export function catalogFactions() {
953
+ return listOfKind('faction', factionFor);
954
+ }
930
955
  /** Every metamagic and echo the resolved catalog knows. */
931
956
  export function catalogMetamagics() {
932
957
  return listOfKind('metamagic', metamagicFor);
@@ -95,7 +95,22 @@ export const SHOUT_METERS = 50;
95
95
  * shrink the room to nothing.
96
96
  */
97
97
  export function hearingRangeMeters(listener) {
98
- const bonus = listener.augBonus('perception') + listener.qualityBonus('perception');
98
+ // AN EAR IS NOT AN EYE (aCErAfvEW7G23FWDM: "a human officer standing
99
+ // 20 meters away... shouldn't overhear w/o augmentation").
100
+ //
101
+ // This used to read augBonus('perception'), which is ONE FLAT LANE
102
+ // OVER EVERY SENSE -- so cybereyes (+2) and vision-enhancement-3 (+3)
103
+ // extended how far their owner could HEAR. Nobody noticed because a
104
+ // perception POOL genuinely does not care which sense noticed; it
105
+ // only became wrong when this function made that pool a distance.
106
+ // Audio chrome now declares `hearing` (augmentations.ts AugBonusKind,
107
+ // and the same lane on the site's catalog rows).
108
+ //
109
+ // QUALITIES STAY ON `perception` and that is not an oversight: the
110
+ // quality this fans in is Perceptive, which is a whole-sense trait
111
+ // ("you notice things"), not a piece of ear. A quality that meant
112
+ // hearing specifically would declare `hearing` like any row.
113
+ const bonus = listener.augBonus('hearing') + listener.qualityBonus('perception');
99
114
  return Math.max(SPEECH_METERS, SPEECH_METERS + bonus);
100
115
  }
101
116
  /**
@@ -134,6 +149,28 @@ export function hearingRangeMeters(listener) {
134
149
  * and inventing one would refuse speech in the small abstract rooms
135
150
  * where `say` is all there is.
136
151
  */
152
+ /**
153
+ * CAN THIS LISTENER HEAR A SHOUT FROM THERE (aCErAfvEW7G23FWDM).
154
+ *
155
+ * SHOUT_METERS was declared, documented, and never read: Room.shout
156
+ * reached the whole room and measured the distance only to DESCRIBE the
157
+ * shout (voiceTag), never to bound it. Written here beside canHearSpeech
158
+ * for the reason this module exists at all -- two rules answering "who
159
+ * hears this" in two files is how Room.say and murmurAudience drifted
160
+ * apart in the first place.
161
+ *
162
+ * The listener's own range extends a shout exactly as it extends a
163
+ * murmur: the ear is the rule, the mouth only sets the baseline.
164
+ */
165
+ export function canHearShout(speaker, listener) {
166
+ const room = speaker.currentLocation;
167
+ if (!spotsActive(room))
168
+ return true;
169
+ if (spotOf(speaker) === undefined || spotOf(listener) === undefined)
170
+ return true;
171
+ const reach = SHOUT_METERS + (hearingRangeMeters(listener) - SPEECH_METERS);
172
+ return actorDistanceMeters(room, speaker, listener) <= reach;
173
+ }
137
174
  export function canHearSpeech(speaker, listener) {
138
175
  const room = speaker.currentLocation;
139
176
  if (!spotsActive(room))
@@ -8,8 +8,16 @@ import { Logger } from './logger.js';
8
8
  * and nothing happened" cannot recur.
9
9
  */
10
10
  export const PATIENCE_BEATS = 3;
11
- /** What a hostile watching you do it will not let pass (performAction verbs). */
12
- export const PROVOCATION = /^(draws\b|casts\b|chants a summoning)/;
11
+ /**
12
+ * What a hostile watching you do it will not let pass (performAction verbs).
13
+ *
14
+ * `grapples` joined on 2026-09-15 (aCErAfvEW7G23FWDM). Seizing someone
15
+ * is a melee attack in canon (p.195) and reads as one to anybody in the
16
+ * room, but the ladder did not list it -- so a hostile could stand and
17
+ * watch you lay hands on their colleague and count it as patience. The
18
+ * verb is the one grapple.ts passes to performAction.
19
+ */
20
+ export const PROVOCATION = /^(draws\b|casts\b|chants a summoning|grapples\b)/;
13
21
  /** The hostiles in `room` who could open on `player` -- structural, no LLM. */
14
22
  export function hostilesIn(scene, room, player) {
15
23
  const out = [];
@@ -43,6 +51,44 @@ export function standDown(npc) {
43
51
  npc.hostile = false;
44
52
  npc.hostileContact = undefined;
45
53
  }
54
+ /**
55
+ * THE MIRROR OF standDown, and until 2026-09-15 it did not exist
56
+ * (aCErAfvEW7G23FWDM).
57
+ *
58
+ * `hostile` was written at BOOT and nowhere else -- scene-factory from
59
+ * the seed, street-gang at ganger spawn, ic-actors for a program. The
60
+ * only runtime writer in the whole engine was standDown, which can
61
+ * only ever turn hostility OFF. So an NPC who was not hostile when the
62
+ * scene was built had no path to becoming hostile, whatever you did to
63
+ * them: the bartender you grabbed by the collar could be provoked in
64
+ * prose and never in fact.
65
+ *
66
+ * ONE WRITER, here, for the same reason hostile-contact.ts has always
67
+ * insisted on one opener (see the header): hostility that can be set
68
+ * from anywhere is hostility nobody can explain afterwards. Callers
69
+ * pass WHY, and it is logged.
70
+ *
71
+ * This does NOT open a fight by itself. It puts the NPC on the ladder
72
+ * -- noticed, and provoked as of this beat -- and the ordinary
73
+ * hostileContactBeat decides when they act on it. An act that should
74
+ * start a fight NOW opens the encounter itself (grapple.ts, attack.ts).
75
+ */
76
+ export function turnHostile(npc, beat, why) {
77
+ if (npc.hostile === true) {
78
+ // Already opposition: the act still counts as provocation, which is
79
+ // what moves a patient hostile off the fence.
80
+ registerProvocation(npc, beat);
81
+ return;
82
+ }
83
+ // A COMPANION IS NEVER TURNED. A bound spirit or a hired drone
84
+ // answers to its master (models/npc.ts allyOf); flipping one hostile
85
+ // from a passing act would hand the player's own crew to the scene.
86
+ if (npc.allyOf)
87
+ return;
88
+ npc.hostile = true;
89
+ npc.hostileContact = { noticedBeat: beat, provokedBeat: beat };
90
+ Logger.getInstance().write(`${npc.name} turns hostile: ${why}.`);
91
+ }
46
92
  function onPhysicalPlane(player) {
47
93
  return player.plane === 'meat' || player.plane === 'drone';
48
94
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * WHAT A BEAT MAY CLAIM (aCErAfvEW7G23FWDM).
3
+ *
4
+ * THE LINE THIS EXISTS FOR, from session 2026-09-15T14-45-01-617Z at
5
+ * 11:05:43, three seconds after the player typed "jack in":
6
+ *
7
+ * "Ms. Quill plants a hand on Ted's shoulder, not gentle, steering
8
+ * him toward the door before Kessler decides to make this paperwork."
9
+ *
10
+ * Nothing happened. No dice were rolled, no defence was offered, no
11
+ * Combat Turn opened, and Ted -- who was on the Ares grid with his body
12
+ * slumped in a bar -- had no way to answer any of it. The sentence was
13
+ * EXPOSITION: a non-command line of a model reply, printed to the
14
+ * player verbatim (models/npc.ts). The engine never saw an act at all,
15
+ * which is why no rule caught it.
16
+ *
17
+ * THE REPORTER'S OWN RULE, which is the one implemented here: "'plants
18
+ * hand on Ted's shoulder' is an emote, and that's fine, but moving
19
+ * someone against their will is a hostile action, and should start some
20
+ * sort of combat."
21
+ *
22
+ * So the boundary is not violence and it is not touch. It is AGENCY
23
+ * OVER SOMEBODY ELSE'S BODY. A beat may do anything it likes with the
24
+ * NPC's own body, mood, tone, props and surroundings -- that is the
25
+ * writing, and the writing is good. It may not move, hold, or lay hands
26
+ * on another actor, because those have mechanics (SR5 p.195 Subduing,
27
+ * resolved as an opposed Unarmed melee attack) and prose is not allowed
28
+ * to skip them.
29
+ *
30
+ * WHAT HAPPENS TO A CAUGHT LINE. It is suppressed and the NPC is told
31
+ * why, in the same breath, so the next beat can COMMIT to the act as a
32
+ * command instead of asserting it as a fact -- where the authorization
33
+ * gate, the dice and the Combat Turn all get their say. Refusing
34
+ * silently would just produce a mute NPC and a confused model.
35
+ *
36
+ * WHY A PREDICATE AND NOT A MODEL INSTRUCTION. The prompt has told the
37
+ * model to behave for a long time; it says "Do NOT repeat, rephrase, or
38
+ * re-ask" and the same session has an NPC delivering one hook twice in
39
+ * two phrasings. An instruction is advice. This is the engine.
40
+ *
41
+ * Exported and PURE for the same reason isOutOfCharacter is: it fails
42
+ * silently -- a pattern that stops matching does not throw, the line
43
+ * simply prints, and the only detector is a player watching their
44
+ * Johnson march them out of a bar they were not standing in.
45
+ */
46
+ /**
47
+ * PUTTING HANDS ON SOMEBODY. Deliberately anchored on the ACT, not on
48
+ * the body part: "a hand on their shoulder" and "a hand on their arm"
49
+ * are the same claim, and enumerating anatomy is how a list like this
50
+ * rots.
51
+ *
52
+ * `grabs|seizes|...` carry their inflections because a beat is written
53
+ * in the present tense but a reflect line may not be.
54
+ */
55
+ const CONTACT = new RegExp([
56
+ // "plants/lays/claps/puts/sets a hand on", and the bare "a hand on".
57
+ String.raw `\b(?:plant|lay|lays|clap|put|puts|set|sets|place|places|rest|rests|drop|drops)\w*\s+(?:a|her|his|their|one)\s+hands?\s+(?:on|against|to)\b`,
58
+ // "...hand closes around", whoever the hand belongs to. No determiner
59
+ // required: the fiction writes "Quill's hand closes around Ted's arm"
60
+ // as readily as "a hand closes", and both are the same claim.
61
+ String.raw `\bhands?\s+(?:lands?|closes?|clamps?|settles?|falls?|tightens?|locks?)\s+(?:on|around|over|about)\b`,
62
+ // Seizing and holding.
63
+ String.raw `\b(?:grab|grabs|grabbed|grabbing|seiz\w+|grip\w*|clutch\w*|clamp\w*|catch\w*|snatch\w*|takes?\s+hold|gets?\s+(?:a\s+)?(?:hand|grip|hold))\b`,
64
+ String.raw `\b(?:restrain\w*|pin\w*|wrestl\w*|manhandl\w*|hold\w*\s+(?:down|back|still))\b`,
65
+ // "holds Maka pinned", where the person sits BETWEEN the verb and the
66
+ // word that makes it a hold. The aimed-at test reads forward from a
67
+ // match, so the name has to be inside the match for this shape --
68
+ // found by sweeping the transcripts, where it is how a continuing
69
+ // grapple is always written.
70
+ String.raw `\bhold\w*\s+\w+(?:'s|’s)?\s*(?:pinned|down|still|fast|against|in place)\b`,
71
+ // Striking, shoving -- these ARE attacks and have their own verbs.
72
+ String.raw `\b(?:shov\w+|push\w*|punch\w*|slap\w*|strik\w+|kick\w*|slam\w*|throw\w*)\b`,
73
+ // "takes/catches them by the arm" -- the classic escort grip.
74
+ String.raw `\b(?:take|takes|took|catch|catches|caught|hook|hooks)\s+\w+(?:'s|’s)?\s*\w*\s+by\s+the\s+\w+`,
75
+ ].join('|'), 'i');
76
+ /**
77
+ * MOVING SOMEBODY WHO DID NOT CHOOSE TO MOVE. The engine has no
78
+ * compelled-movement mechanic at all -- nothing anywhere writes another
79
+ * actor's position, and `follow` is opt-in with a leash that breaks the
80
+ * moment you are grappled. So this is not "use the other verb", it is
81
+ * "this cannot happen": the canon answer to wanting someone somewhere
82
+ * else is to hold them (p.195) and let them decide.
83
+ */
84
+ const COMPELLED_MOVEMENT = new RegExp([
85
+ // INFLECTED FORMS ONLY for the ones whose stem is also a common NOUN.
86
+ // `bundl\w+` matched the item "Street Ammo Bundle" and turned a vendor
87
+ // receipt into an abduction; `herd` and `hustle` are nouns too. Same
88
+ // lesson as `tow` below, found the same way -- by sweeping the gate
89
+ // over every transcript on disk rather than by reading the pattern.
90
+ String.raw `\b(?:steer\w*|guid\w+|shepherd\w*|usher\w*|escort\w*|march\w*|propel\w*|herd(?:s|ed|ing)|bundl(?:es|ed|ing)|hustl(?:es|ed|ing)|hurr(?:y|ies|ied|ying))\b`,
91
+ // `tow` is spelled out rather than stemmed: `tow\w*` matches TOWARD,
92
+ // which appears in half the good lines in any transcript and quietly
93
+ // turned "slides a cred stick across the table toward Ted" into an
94
+ // abduction. A stem is only safe when no innocent word starts with it.
95
+ String.raw `\b(?:drag\w*|haul\w*|pull\w*|tug\w*|yank\w*|tows?|towed|towing|carr(?:y|ies|ied|ying)|lift\w*)\b`,
96
+ String.raw `\b(?:walk\w*|mov\w+|lead\w*|tak\w+)\s+\w+\s+(?:toward|towards|to|out|off|away|through|into|back)\b`,
97
+ ].join('|'), 'i');
98
+ const ALLOWED = { refused: false };
99
+ /**
100
+ * THE WORDS OF A NAME THAT CAN STAND FOR IT.
101
+ *
102
+ * "Ms. Quill" is abbreviated to "Quill" in every transcript, so a name
103
+ * has to match on its parts -- but ONLY the parts that identify
104
+ * somebody. A scene may legitimately hold an NPC called "The Drowned
105
+ * Choir" or a bar called "The Blind Eye", and letting "The" stand for
106
+ * either turns every "the cup" in the room into a person. Found by
107
+ * sweeping the gate over every transcript on disk: it was the last
108
+ * false positive left standing.
109
+ */
110
+ const NOT_A_NAME = /^(?:the|and|of|an|mr|ms|mrs|dr|de|la|le|van|von)\.?$/i;
111
+ function nameForms(name) {
112
+ const parts = name.split(/\s+/).filter(p => p.length > 2 && !NOT_A_NAME.test(p));
113
+ return [name, ...parts].map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
114
+ }
115
+ /**
116
+ * A possessive or object mention of somebody who is NOT the speaker.
117
+ *
118
+ * Names, not pronouns, and that is the whole reason this is tractable.
119
+ * A beat that says "steering him toward the door" with no name in it
120
+ * has no referent the engine can check, so it is left alone -- the
121
+ * reported line is caught because it says "Ted's shoulder" and
122
+ * "steering him" in ONE sentence, which is how these are actually
123
+ * written. Chasing pronouns across sentences would cost far more
124
+ * false positives than it would catch real ones.
125
+ */
126
+ function subjectIn(line, others) {
127
+ for (const name of others) {
128
+ if (!name)
129
+ continue;
130
+ const forms = nameForms(name);
131
+ const re = new RegExp(`(?:^|[^\\w])(?:${forms.join('|')})(?:'s|’s)?(?:[^\\w]|$)`, 'i');
132
+ if (re.test(line))
133
+ return name;
134
+ }
135
+ return undefined;
136
+ }
137
+ /**
138
+ * DOES THE VERB LAND ON THIS PERSON?
139
+ *
140
+ * THE FALSE POSITIVES THIS EXISTS FOR, every one a verbatim line from a
141
+ * real session:
142
+ * "Ms. Veil lifts the cup, watches Jynx over its rim."
143
+ * "Jynx holds the camera out; Mr. Ibis snatches it up with both hands."
144
+ * "Rhen pushes off the wall, inching toward the doorway where Vek stands."
145
+ * "Brick's cyber-arms whir, the ledger clutched in his hands."
146
+ * Each has a contact verb AND somebody else's name, and each is good
147
+ * writing about an OBJECT or about the speaker's own body. A name
148
+ * somewhere in the sentence is not enough.
149
+ *
150
+ * Approximate and deliberately so: what follows a verb within a few
151
+ * words is its object far more often than not, and the alternative is
152
+ * a parser. Two shapes count as aimed at somebody:
153
+ *
154
+ * 1. the name (or an object pronoun) inside the next ~30 characters
155
+ * -- "grabs Ted", "steers him toward the door", "holds Maka pinned";
156
+ * 2. the possessive-plus-body-part form the hand patterns produce --
157
+ * "a hand on Ted's shoulder", where the name follows a preposition.
158
+ *
159
+ * "lifts the cup", "snatches it up" and "pushes off the wall" match
160
+ * neither, which is the entire point.
161
+ */
162
+ function aimedAtAPerson(text, matches, subject) {
163
+ const target = new RegExp(`(?:${nameForms(subject).join('|')})|\\b(?:him|her|them|themselves)\\b`, 'i');
164
+ for (const m of matches) {
165
+ if (!m || m.index === undefined)
166
+ continue;
167
+ // THE MATCH ITSELF COUNTS, because some patterns swallow their own
168
+ // object: the compelled-movement rule is written as
169
+ // "walk|move|lead|take <something> toward", so in "walks Ted toward
170
+ // the door" the name is INSIDE the match and everything after it is
171
+ // furniture.
172
+ //
173
+ // SIXTEEN CHARACTERS, about three words, and the number is doing
174
+ // real work rather than being round. At thirty, "Ms. Veil lifts the
175
+ // cup, watches Jynx over its rim" reaches Jynx and a lovely line
176
+ // dies; at sixteen it does not, while "grabs Ted by the collar" and
177
+ // "steering him toward the door" both still land.
178
+ const end = m.index + m[0].length;
179
+ if (target.test(m[0] + text.slice(end, end + 16)))
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+ /**
185
+ * MAY THIS BEAT SAY THIS?
186
+ *
187
+ * @param line one exposition line of a model reply
188
+ * @param speaker the NPC whose beat it is
189
+ * @param others every other actor present, by name
190
+ * @param opts.holding who this NPC already has a grapple on, if anyone --
191
+ * narration about a hold the dice already settled is a
192
+ * description, not a claim.
193
+ */
194
+ export function judgeNarration(line, speaker, others, opts = {}) {
195
+ const t = line.replace(/^[\s│|>*-]+/, '').trim();
196
+ if (t.length === 0)
197
+ return ALLOWED;
198
+ const contactAt = t.match(CONTACT);
199
+ const movedAt = t.match(COMPELLED_MOVEMENT);
200
+ if (!contactAt && !movedAt)
201
+ return ALLOWED;
202
+ // WHO IS THE VERB AIMED AT -- asked of EVERY candidate rather than
203
+ // asked once of whoever is named first.
204
+ //
205
+ // Found by sweeping the transcripts: "Brick holds Maka pinned against
206
+ // the grate, his scarred face grim as he glances at Patches' slumped
207
+ // form" names two people. Resolving the subject first picked Patches
208
+ // (merely mentioned), then asked whether the verb was aimed at
209
+ // Patches, decided no, and let a real hold through. The question has
210
+ // to be asked per candidate or the answer depends on word order.
211
+ const subject = others
212
+ .filter(n => n && n !== speaker)
213
+ .find(n => subjectIn(t, [n]) && aimedAtAPerson(t, [contactAt, movedAt], n));
214
+ if (!subject)
215
+ return ALLOWED;
216
+ // ALREADY HOLDING THEM. If the engine says this NPC has a grapple on
217
+ // that target, then "Brick holds Maka pinned against the grate" is
218
+ // not a claim -- it is a DESCRIPTION of a state the dice already
219
+ // settled, and refusing it would gag the fiction about the one thing
220
+ // that IS mechanically true. Measured against every transcript on
221
+ // disk: without this, three of the nineteen refusals were an NPC
222
+ // narrating its own real grapple.
223
+ if (opts.holding && opts.holding === subject)
224
+ return ALLOWED;
225
+ // WHICH REFUSAL, because they are different rules with different
226
+ // answers. Contact has a verb and a rule behind it and the NPC should
227
+ // be told to use them. Compelled movement has NEITHER -- no mechanic
228
+ // exists for moving a body that did not choose to move -- so the note
229
+ // says the true thing rather than naming a verb that will not work.
230
+ const note = contactAt
231
+ ? `NARRATION CANNOT PUT HANDS ON ${subject.toUpperCase()}. Your last beat described doing it anyway, so it did not happen and the player was not shown it. Laying hold of someone is an Unarmed attack they get to defend against (Subduing, p.195) and it starts a fight. If you mean it, commit to it: "[COMMAND] grapple ${subject}". If you do not, describe your own body instead -- what your hands do short of touching them is yours to narrate.`
232
+ : `YOU CANNOT MOVE ${subject.toUpperCase()}. Your last beat described steering or carrying them somewhere, so it did not happen and the player was not shown it. Where a runner's body goes is theirs alone -- there is no way for you to walk them anywhere. Hold them where they stand if you must ("[COMMAND] grapple ${subject}", which starts a fight), ask them to come, or let them stay.`;
233
+ return { refused: true, subject, note };
234
+ }
235
+ //# sourceMappingURL=narration-limits.js.map
@@ -0,0 +1,122 @@
1
+ import { fuzzyPickName } from './fuzzy-match.js';
2
+ const ALLOWED = { allowed: true };
3
+ /**
4
+ * VERBS THAT PUT HANDS ON SOMEONE. The distinction that matters is not
5
+ * "is this violent" but "does this require the target's body to be
6
+ * there and able to answer for itself". Subduing (SR5 p.195) is the
7
+ * canonical one: it deals no damage at all and is still an Unarmed
8
+ * melee attack the target gets to defend against.
9
+ */
10
+ const CONTACT_VERBS = new Set([
11
+ 'attack', 'kill', 'subdue', 'grapple', 'restrain', 'clinch', 'struggle',
12
+ 'palm', 'pick',
13
+ ]);
14
+ /** Verbs whose first argument names another actor rather than an item
15
+ * or a direction. Anything not listed here is authorized on the actor
16
+ * alone -- we never guess a target out of an item name. */
17
+ const ACTOR_TARGET_VERBS = new Set([
18
+ ...CONTACT_VERBS,
19
+ 'cast', 'lead', 'order', 'give', 'talk-to', 'tell', 'treat', 'follow',
20
+ ]);
21
+ /**
22
+ * The target named in the arguments, if this verb names one and the
23
+ * name resolves to somebody in the room. Deliberately the same fuzzy
24
+ * resolution the combat verbs use (utilities/fuzzy-match.ts), so a
25
+ * refusal and an execution disagree about who was meant exactly never.
26
+ *
27
+ * Returns undefined when no target is named OR when the name does not
28
+ * resolve -- an unresolvable name is the COMMAND's business to complain
29
+ * about ("You don't see X here"), not this module's. Refusing it here
30
+ * would replace a specific, useful refusal with a vague one.
31
+ */
32
+ function namedTarget(scene, actor, verb, args) {
33
+ if (!ACTOR_TARGET_VERBS.has(verb))
34
+ return undefined;
35
+ const words = args.filter(w => !/^(the|a|an|at|to|with|from)$/i.test(w));
36
+ if (words.length === 0)
37
+ return undefined;
38
+ const room = actor.currentLocation;
39
+ if (!room)
40
+ return undefined;
41
+ // EVERY actor in the room, not the perceivable ones: this function
42
+ // answers "who did it mean", and the perception rule below is what
43
+ // answers "may it". Filtering by perception here would turn "you
44
+ // cannot see them" into a silent no-target and lose the reason.
45
+ const candidates = scene.getActorsInRoom(room).filter(a => a !== actor);
46
+ const picked = fuzzyPickName(words.join(' '), candidates.map(a => a.name));
47
+ return picked ? candidates.find(a => a.name === picked) : undefined;
48
+ }
49
+ /**
50
+ * MAY THIS AI-DRIVEN ACTOR RUN THIS COMMAND, RIGHT NOW.
51
+ *
52
+ * Called from CommandRegistry.parse for any command declaring
53
+ * npcPolicy 'authorized', and only for actors the scene does not
54
+ * consider human-controlled.
55
+ */
56
+ export function authorizeNpcAction(scene, actor, verb, args) {
57
+ const target = namedTarget(scene, actor, verb, args);
58
+ if (!target)
59
+ return ALLOWED;
60
+ const contact = CONTACT_VERBS.has(verb);
61
+ // ORDER MATTERS HERE, and it is a writing decision as much as a rules
62
+ // one. A jacked-in runner fails BOTH tests below -- cross-plane
63
+ // perception and body presence -- and the perception rule would fire
64
+ // first if it were written first, refusing with "you cannot perceive
65
+ // them". That is true of the PERSONA and useless about the BODY,
66
+ // which is lying right there in the room and is what the act was
67
+ // reaching for. For a hands-on verb the absent-body reason is the one
68
+ // that tells the fiction something it can use.
69
+ if (contact && !isPresentInBody(target)) {
70
+ return {
71
+ allowed: false,
72
+ reason: `You can't lay hands on ${target.name}: they are not present in their body, and there is nobody home to resist you. Describe what you see, or wait for them to come back.`,
73
+ };
74
+ }
75
+ // YOU CANNOT ACT ON WHAT YOU CANNOT PERCEIVE. The engine's single
76
+ // cross-plane rule (Player.canPerceive) already gates hear() and
77
+ // see(), so an NPC never even WAKES for someone on another plane --
78
+ // but a beat woken by something else could still name them in a
79
+ // command, and did.
80
+ if (!actor.canPerceive(target)) {
81
+ return {
82
+ allowed: false,
83
+ reason: `You can't act on ${target.name} -- they are not something you can perceive from where you stand.`,
84
+ };
85
+ }
86
+ if (!contact)
87
+ return ALLOWED;
88
+ // LAYING HANDS ON SOMEONE WHO CANNOT ANSWER.
89
+ //
90
+ // This is the rung the reported session actually broke, and the
91
+ // reporter named the rule themselves: an emote is fine, "but moving
92
+ // someone against their will is a hostile action, and should start
93
+ // some sort of combat". Ms. Quill planted a hand on Ted's shoulder
94
+ // and steered him toward the door while Ted was JACKED IN -- a body
95
+ // slumped in a bar with nobody home to roll a defence.
96
+ //
97
+ // SR5 has no rule for this because SR5 assumes a person is present to
98
+ // defend: Subduing (p.195) is resolved as an opposed melee attack,
99
+ // and there is nothing to oppose. Rather than invent a defence pool
100
+ // for an empty body, the engine refuses the act. A runner's unattended
101
+ // body is not a prop the fiction may move.
102
+ if (target.isIncapacitated()) {
103
+ return {
104
+ allowed: false,
105
+ reason: `You can't -- ${target.name} is already down and cannot defend themselves.`,
106
+ };
107
+ }
108
+ return ALLOWED;
109
+ }
110
+ /**
111
+ * Is this actor home? A decker jacked into the Matrix, a magician
112
+ * projecting astrally and a rigger jumped into a drone all leave a body
113
+ * behind that is still listed in the room.
114
+ *
115
+ * `drone` is deliberately NOT here: a rigger jumped into an airframe is
116
+ * a physical machine in the meat world with its own defence, and
117
+ * grabbing one is an ordinary act of violence the engine can resolve.
118
+ */
119
+ function isPresentInBody(actor) {
120
+ return actor.plane === 'meat' || actor.plane === 'drone';
121
+ }
122
+ //# sourceMappingURL=npc-authorization.js.map