@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.198.0",
3
+ "version": "5.199.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -33,6 +33,9 @@ import { spendMovementMeters } from '../utilities/movement-cost.js';
33
33
  */
34
34
  export class AttackCommand extends Command {
35
35
  static verb = 'attack';
36
+ /** Touches another actor: authorized per-act for AI-driven actors
37
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
38
+ static npcPolicy = 'authorized';
36
39
  static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197).';
37
40
  async execute(args = []) {
38
41
  if (!args || args.length === 0) {
@@ -89,6 +89,9 @@ const COMBAT_SPELL_FLAVOR = {
89
89
  */
90
90
  export class CastCommand extends BypassCommand {
91
91
  static verb = 'cast';
92
+ /** Touches another actor: authorized per-act for AI-driven actors
93
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
94
+ static npcPolicy = 'authorized';
92
95
  static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Bare "cast" tries to unweave a warded barrier.';
93
96
  deviceKind = 'ward';
94
97
  verbName = 'cast';
@@ -1,6 +1,7 @@
1
1
  // src/commands/command-registry.ts
2
2
  import { CommandFactory } from '../factories/command-factory.js';
3
3
  import { Logger } from '../utilities/logger.js';
4
+ import { authorizeNpcAction } from '../utilities/npc-authorization.js';
4
5
  export class CommandRegistry {
5
6
  scene;
6
7
  constructor(scene) {
@@ -12,6 +13,10 @@ export class CommandRegistry {
12
13
  * execute() internally, and callers ran it a second time on top of that
13
14
  * with no arguments at all, which crashed any command whose execute()
14
15
  * doesn't guard against a missing `args` array.)
16
+ *
17
+ * `null` = no such verb. A result carrying `refusal` = a real verb the
18
+ * engine refused for this actor; `command` is absent and the caller
19
+ * must not execute anything.
15
20
  */
16
21
  async parse(commandString, actor) {
17
22
  const parts = commandString.trim().split(' ');
@@ -49,6 +54,26 @@ export class CommandRegistry {
49
54
  Logger.getInstance().write(`Refused "${verb}" from ${actor.name}: that verb is the player's alone.`);
50
55
  return null;
51
56
  }
57
+ // THE AI PROPOSES, THE ENGINE DISPOSES (aCErAfvEW7G23FWDM).
58
+ //
59
+ // The same choke point, one question further out: humanOnly asks
60
+ // whether this verb belongs to the player's ACCOUNT, and this asks
61
+ // whether an AI-driven actor may do this PARTICULAR thing to this
62
+ // PARTICULAR target right now. An NPC is entitled to attack; it was
63
+ // equally entitled, until this line, to lay hands on a body whose
64
+ // owner was jacked into the Matrix and could not roll a defence.
65
+ //
66
+ // The refusal REASON is returned to the caller rather than swallowed
67
+ // -- NPC.act feeds it back to the model as a system note, so the
68
+ // boundary is something the fiction learns instead of something it
69
+ // grinds against.
70
+ if (declaring.getNpcPolicy() === 'authorized' && !this.scene.isHumanControlled(actor)) {
71
+ const verdict = authorizeNpcAction(this.scene, actor, verb, args);
72
+ if (!verdict.allowed) {
73
+ Logger.getInstance().write(`Refused "${commandString.trim()}" from ${actor.name}: ${verdict.reason}`);
74
+ return { args, refusal: verdict.reason ?? `That is not yours to do.` };
75
+ }
76
+ }
52
77
  return { command, args };
53
78
  }
54
79
  }
@@ -21,6 +21,31 @@ export class Command {
21
21
  static isHumanOnly() {
22
22
  return this.humanOnly;
23
23
  }
24
+ /**
25
+ * WHAT AN AI-DRIVEN ACTOR MAY DO WITH THIS VERB (aCErAfvEW7G23FWDM,
26
+ * reporter's ruling: "the only way to solve these is with engine
27
+ * enforcement against AI allowed actions").
28
+ *
29
+ * `humanOnly` above answers "is this the player's account", which is a
30
+ * narrower question than "is this the AI's to do". A verb that puts
31
+ * hands on somebody, moves them, or changes the world is not
32
+ * account-scoped at all -- an NPC is entitled to attack, and was
33
+ * equally entitled to attack a body whose owner was jacked into the
34
+ * Matrix and could not roll a defence.
35
+ *
36
+ * 'open' harmless in an NPC's hands -- look, say, the default.
37
+ * 'human-only' the player's alone; kept in lockstep with humanOnly.
38
+ * 'authorized' must pass utilities/npc-authorization.ts first.
39
+ *
40
+ * Declared on the command, enforced once in CommandRegistry.parse, and
41
+ * a test scans this directory for the world-touching verb that forgets.
42
+ */
43
+ static npcPolicy = 'open';
44
+ static getNpcPolicy() {
45
+ // One source of truth: a verb that declares humanOnly IS human-only,
46
+ // whether or not it also remembered to say so here.
47
+ return this.humanOnly ? 'human-only' : this.npcPolicy;
48
+ }
24
49
  static verb;
25
50
  static description; // Make this static
26
51
  actor;
@@ -132,7 +132,7 @@ export class CompileCommand extends Command {
132
132
  const resist = rollPool(Math.max(1, actor.willpower + actor.resonance));
133
133
  const fadingTaken = Math.max(0, fadingValue - resist.hits);
134
134
  actor.performAction('goes still, eyes tracking code that is not there', 'threading a sprite');
135
- this.scene.addWorldEvent(`${actor.name} compiled a sprite in ${actor.currentLocation.name}.`);
135
+ this.scene.addWorldEvent(`${actor.name} compiled a sprite in ${actor.currentLocation.name}.`, { plane: 'matrix' });
136
136
  this.logger.write(`CompileCommand: ${actor.name} ${typeKey} level ${level} -- ${mine.hits} vs ${sprite.hits}, tasks ${tasks}, fading ${fadingValue} (took ${fadingTaken}${overreach ? ' physical' : ''}).`);
137
137
  const poolParts = [`Resonance ${actor.resonance}`, compiling > 0 ? `compiling ${compiling}` : `no compiling -1`];
138
138
  if (actor.woundModifier < 0)
@@ -161,18 +161,18 @@ export class DecompileCommand extends Command {
161
161
  const lines = [`You find ${target.name}'s thread and PULL AGAINST THE WEAVE:`];
162
162
  if (net <= 0) {
163
163
  lines.push(` Its code holds -- and now it KNOWS you, the touch traced straight back to your persona.`);
164
- this.scene.addWorldEvent(`${actor.name} tried to decompile ${target.name} in ${room.name} -- and failed. It is alert.`);
164
+ this.scene.addWorldEvent(`${actor.name} tried to decompile ${target.name} in ${room.name} -- and failed. It is alert.`, { plane: 'matrix' });
165
165
  }
166
166
  else {
167
167
  const dmg = net + Math.floor(actor.resonance / 2);
168
168
  target.takeDamage(dmg);
169
169
  if (target.isDown()) {
170
- this.scene.addWorldEvent(`${actor.name} decompiled ${target.name} in ${room.name} -- unraveled to static.`);
170
+ this.scene.addWorldEvent(`${actor.name} decompiled ${target.name} in ${room.name} -- unraveled to static.`, { plane: 'matrix' });
171
171
  this.scene.removeActor(target.name);
172
172
  lines.push(` Thread by thread it comes APART -- ${dmg} boxes -- and ${target.name} unravels into clean static. Decompiled.`);
173
173
  }
174
174
  else {
175
- this.scene.addWorldEvent(`${actor.name} tore at ${target.name}'s code in ${room.name}.`);
175
+ this.scene.addWorldEvent(`${actor.name} tore at ${target.name}'s code in ${room.name}.`, { plane: 'matrix' });
176
176
  lines.push(` Threads tear loose -- ${dmg} boxes: it holds together, barely, and it's coming for you.`);
177
177
  }
178
178
  }
@@ -111,7 +111,7 @@ export class EditFileCommand extends Command {
111
111
  room.inventory.removeItem(file.name);
112
112
  room.plainSightItems.delete(file.name.toLowerCase());
113
113
  this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${resist.hits}.`);
114
- this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`);
114
+ this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`, { plane: 'matrix' });
115
115
  const banner = this.scene.checkErased?.(file.name, actor.name);
116
116
  this.scene.updateStatus();
117
117
  return [
@@ -4,6 +4,9 @@ import { sameSpot, spotOf, spotRefusal } from '../utilities/spots.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  export class GiveCommand extends Command {
6
6
  static verb = 'give';
7
+ /** Touches another actor: authorized per-act for AI-driven actors
8
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
9
+ static npcPolicy = 'authorized';
7
10
  static description = 'Give an item or money to someone.';
8
11
  async execute(args) {
9
12
  if (!args)
@@ -5,6 +5,7 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  import { physicalLimit, heldBy, holding, releaseGrapple } from '../utilities/grapple.js';
6
6
  import { spotOf, ensureAtSpot } from '../utilities/spots.js';
7
7
  import { billAction } from '../utilities/action-cost.js';
8
+ import { arenaOf } from '../utilities/combat-turn.js';
8
9
  /**
9
10
  * SUBDUING (SR5e p.195) -- "grapple"/"restrain"/"clinch": seize someone
10
11
  * and hold them, dealing NO damage. The RAW shape, faithfully: a normal
@@ -20,6 +21,9 @@ import { billAction } from '../utilities/action-cost.js';
20
21
  */
21
22
  export class GrappleCommand extends Command {
22
23
  static verb = 'grapple';
24
+ /** Touches another actor: authorized per-act for AI-driven actors
25
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
26
+ static npcPolicy = 'authorized';
23
27
  static description = 'Seize and restrain someone (SR5e Subduing, p.195) -- no damage, they can\'t move or fight until they break free ("struggle"). "release" lets them go. Also: "restrain", "clinch".';
24
28
  async execute(args = []) {
25
29
  const actor = this.actor;
@@ -113,6 +117,7 @@ export class GrappleCommand extends Command {
113
117
  if (net <= 0) {
114
118
  actor.performAction('grapples', `at ${target.name} -- and comes up with air`);
115
119
  this.finishMeta(isPlayer, meta, lines);
120
+ await this.openFightOverTheGrab(target);
116
121
  return `${crossNote}${lines.join('\n')}You lunge for ${target.name} -- they twist clear. Now they know exactly what you want.`;
117
122
  }
118
123
  // Strength + net hits must EXCEED the Physical limit (p.195).
@@ -122,6 +127,7 @@ export class GrappleCommand extends Command {
122
127
  this.finishMeta(isPlayer, meta, lines);
123
128
  if (grip <= limit) {
124
129
  actor.performAction('grapples', `${target.name} -- a grip, but not a hold; they wrench loose`);
130
+ await this.openFightOverTheGrab(target);
125
131
  return `${crossNote}${lines.join('\n')}You get hands on ${target.name}, but they're too solid to hold -- they wrench loose.`;
126
132
  }
127
133
  target.grappledBy = actor.name;
@@ -129,9 +135,52 @@ export class GrappleCommand extends Command {
129
135
  actor.grappling = target.name;
130
136
  actor.performAction('grapples', `${target.name} and locks them down -- held fast, unable to move`);
131
137
  this.scene.addWorldEvent(`${actor.name} grappled and restrained ${target.name} in ${room.name}.`);
138
+ await this.openFightOverTheGrab(target);
132
139
  this.scene.updateStatus();
133
140
  return `${crossNote}${lines.join('\n')}You seize ${target.name} and lock the hold -- HELD FAST, no damage done. They can't move, flee, or fight until they break your grip${isPlayer ? `, and your hands are full until you "release"` : ''}.${hint(` (They escape only by beating ${net} hits on Unarmed + Strength.)`)}`;
134
141
  }
142
+ /**
143
+ * SEIZING SOMEONE STARTS A FIGHT (aCErAfvEW7G23FWDM: "moving someone
144
+ * against their will is a hostile action, and should start some sort
145
+ * of combat").
146
+ *
147
+ * IT DID NOT, AND THAT WAS THE HOLE. Subduing is a melee attack in
148
+ * canon (p.195) and this command has always rolled it as one -- but
149
+ * `attack` opens a Combat Turn (attack.ts, Scene.startEncounter) and
150
+ * `grapple` never did. The only cost it paid was billAction, which is
151
+ * a NO-OP outside combat (action-cost.ts). So laying hands on someone
152
+ * in a quiet room cost nothing, started nothing, and left the target
153
+ * held with no turn in which to struggle -- the engine's most hostile
154
+ * act was also its cheapest.
155
+ *
156
+ * THE GRAB LANDS FIRST, then the fight opens. That is deliberate and
157
+ * it is the one place this differs from attack.ts, which opens the
158
+ * encounter BEFORE resolving so initiative can put someone faster
159
+ * ahead of the swing. A grapple is the opening act of a fight that
160
+ * did not exist yet -- there is no initiative to lose to, and
161
+ * re-plumbing this command through the phase loop to resolve a grab
162
+ * that has already been rolled would buy nothing but a second copy of
163
+ * executeInEncounter. The target answers on their phase, with
164
+ * "struggle", which is exactly what the turn now gives them.
165
+ */
166
+ async openFightOverTheGrab(target) {
167
+ const actor = this.actor;
168
+ // Already fighting: the turn loop owns this and must not be
169
+ // re-opened underneath itself.
170
+ if (this.scene.encounterFor?.(actor))
171
+ return;
172
+ // Hands up is not a fight (see the surrendered branch above): a
173
+ // zip-tie on someone who already yielded starts nothing.
174
+ if (target.surrendered)
175
+ return;
176
+ if (target.isIncapacitated() || !actor.sharesCombatPlane(target))
177
+ return;
178
+ await this.scene.startEncounter(arenaOf(actor), {
179
+ aggressor: actor,
180
+ target,
181
+ ambush: actor.sneaking,
182
+ });
183
+ }
135
184
  /** NPC actors keep roll anatomy inline for their AI history; the
136
185
  * player's goes to the Mechanics ticker (readability pass). */
137
186
  finishMeta(isPlayer, meta, lines) {
@@ -240,7 +240,7 @@ export class HackCommand extends BypassCommand {
240
240
  room.host?.spotted.add(this.actor.name);
241
241
  this.actor.noteConditionChanged();
242
242
  this.actor.sneaking = false;
243
- this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} MADE ${this.actor.name} mid-intrusion -- alarm flagged, ice hunting.`);
243
+ this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} MADE ${this.actor.name} mid-intrusion -- alarm flagged, ice hunting.`, { plane: 'matrix' });
244
244
  this.scene.updateStatus();
245
245
  return [
246
246
  `The ice turns and LOOKS AT YOU -- your sleaze unravels mid-handshake.`,
@@ -324,7 +324,7 @@ export class HackCommand extends BypassCommand {
324
324
  if (mode === 'attack') {
325
325
  this.actor.sneaking = false;
326
326
  room.hostAlert = true;
327
- this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} takes a forced key -- alarm flagged, ice hunting.`);
327
+ this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} takes a forced key -- alarm flagged, ice hunting.`, { plane: 'matrix' });
328
328
  }
329
329
  lines.push(...godLines);
330
330
  this.scene.updateStatus();
@@ -439,7 +439,7 @@ export class HackCommand extends BypassCommand {
439
439
  // knows it is under attack and starts hunting; what it does not
440
440
  // know is whose hand did it.
441
441
  room.hostAlert = true;
442
- this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} was smashed open by brute force -- its security is down, alarms screaming.`);
442
+ this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} was smashed open by brute force -- its security is down, alarms screaming.`, { plane: 'matrix' });
443
443
  lines.push(` The host SCREAMED on the way down -- everything listening knows it fell, even if nothing saw your hand. Its ice is hunting now: it knows it was hit, not who hit it.`);
444
444
  }
445
445
  else {
@@ -637,7 +637,7 @@ export class HackCommand extends BypassCommand {
637
637
  actor.sneaking = false;
638
638
  if (host) {
639
639
  host.hostAlert = true;
640
- this.scene.addWorldEvent(`${device.name} flags a bad handshake -- ${hostLabel(host, { capital: true })} is awake.`);
640
+ this.scene.addWorldEvent(`${device.name} flags a bad handshake -- ${hostLabel(host, { capital: true })} is awake.`, { plane: 'matrix' });
641
641
  this.scene.updateStatus();
642
642
  return [`${device.name} refuses the key and SAYS SO -- the node it answers to knows something is in here.${wanNote}`, ...godLines].join('\n');
643
643
  }
@@ -9,6 +9,9 @@ import { AttackCommand } from './attack.js';
9
9
  */
10
10
  export class KillCommand extends AttackCommand {
11
11
  static verb = 'kill';
12
+ /** Touches another actor: authorized per-act for AI-driven actors
13
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
14
+ static npcPolicy = 'authorized';
12
15
  static description = 'Alias for "attack" -- resolves as real dice-pool combat, not an instant kill.';
13
16
  }
14
17
  //# sourceMappingURL=kill.js.map
@@ -23,6 +23,9 @@ import { serializeItem } from '../utilities/persistence.js';
23
23
  */
24
24
  export class LeadCommand extends Command {
25
25
  static verb = 'lead';
26
+ /** Touches another actor: authorized per-act for AI-driven actors
27
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
28
+ static npcPolicy = 'authorized';
26
29
  static description = 'Leadership (SR5e p.141): "lead rally" (+initiative, whole party), "lead direct <companion>" (teamwork dice on their next attack), "lead inspire" (primes the party\'s next defense). Bare "lead" shows the party.';
27
30
  leadPool() {
28
31
  const skill = this.actor.skillRating('leadership');
@@ -45,6 +45,9 @@ const EMPTY_ROSTER_PHRASES = Object.values({
45
45
  */
46
46
  export class OrderCommand extends Command {
47
47
  static verb = 'order';
48
+ /** Touches another actor: authorized per-act for AI-driven actors
49
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
50
+ static npcPolicy = 'authorized';
48
51
  static description = 'Command a companion: exact commands run instantly ("order spirit attack Krow"); anything else becomes a standing DIRECTIVE its AI pursues ("order agent to find Mr. Krow"). Spirit tasks cost a service.';
49
52
  async execute(args = []) {
50
53
  const game = this.game;
@@ -15,6 +15,9 @@ import { NPC } from '../models/npc.js';
15
15
  */
16
16
  export class PalmCommand extends Command {
17
17
  static verb = 'palm';
18
+ /** Touches another actor: authorized per-act for AI-driven actors
19
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
20
+ static npcPolicy = 'authorized';
18
21
  static description = 'Sleight of hand (Palming + Agility): lift an item without being seen -- opposed by the sharpest eyes in the room. Winning leaves no witnesses; losing means they watched your hand the whole way.';
19
22
  async execute(args = []) {
20
23
  const actor = this.actor;
@@ -10,6 +10,9 @@ import { AttackCommand } from './attack.js';
10
10
  */
11
11
  export class SubdueCommand extends AttackCommand {
12
12
  static verb = 'subdue';
13
+ /** Touches another actor: authorized per-act for AI-driven actors
14
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
15
+ static npcPolicy = 'authorized';
13
16
  static description = 'Take someone down ALIVE -- stun damage with your bare hands. Slower than shooting, they fight back for real, and overdoing it can still kill them.';
14
17
  async execute(args = []) {
15
18
  // A chokehold needs hands and a neck: no grappling as a Matrix persona
@@ -8,6 +8,9 @@ import { Category } from '../types/shared/item-enum.js';
8
8
  import { billAction } from '../utilities/action-cost.js';
9
9
  export class TakeCommand extends Command {
10
10
  static verb = 'take';
11
+ /** Touches another actor: authorized per-act for AI-driven actors
12
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
13
+ static npcPolicy = 'authorized';
11
14
  static description = 'Pick up an item. "take all" sweeps everything here -- but only after you have searched the room.';
12
15
  async execute(args) {
13
16
  if (!args)
@@ -526,5 +526,56 @@
526
526
  // months, while the web client rendered it perfectly. 134 tags are
527
527
  // now {light-blue-fg}. The site's render-tags.ts maps that back
528
528
  // to a real CSS keyword, so the pin must carry both halves.
529
- export const ENGINE_VERSION = '1.57.0';
529
+ // 1.58.0 (2026-09-15): THE AI PROPOSES, THE ENGINE DISPOSES
530
+ // (aCErAfvEW7G23FWDM, reporter: "the only way to solve these is with
531
+ // engine enforcement against AI allowed actions"). An NPC beat was
532
+ // free prose: any line of it beginning "[COMMAND] " went to the SAME
533
+ // registry the player types into with NO filter on the command text,
534
+ // and every other line was printed to the player as fact. So the
535
+ // engine could neither refuse an act nor even SEE one asserted.
536
+ // - WHAT AN NPC MAY DO. Command.npcPolicy, the same declarative shape
537
+ // as humanOnly, enforced once in CommandRegistry.parse and backed by
538
+ // utilities/npc-authorization.ts. A hands-on verb against a body
539
+ // whose owner is jacked in, projecting, or already down is refused
540
+ // -- SR5 resolves Subduing (p.195) as an OPPOSED melee attack, and
541
+ // there is nothing there to oppose. parse() grew a third outcome:
542
+ // null still means "no such verb", a REFUSAL carries a reason, and
543
+ // NPC.act feeds that reason back so the model hears the boundary.
544
+ // - WHAT A BEAT MAY CLAIM. utilities/narration-limits.ts: narration
545
+ // may do anything with the NPC's own body and nothing with anybody
546
+ // else's. A refused line is suppressed AND answered, so the next
547
+ // beat can commit to "[COMMAND] grapple X" and let the dice decide.
548
+ // - A GRAB IS A FIGHT. commands/grapple.ts never called startEncounter
549
+ // -- it billed through billAction, a no-op outside combat -- so the
550
+ // most hostile act in the engine was also its cheapest, and left the
551
+ // target held with no turn in which to struggle. Every outcome now
552
+ // opens a Combat Turn. `grapples` joins the PROVOCATION ladder, and
553
+ // turnHostile() is the runtime mirror standDown never had: `hostile`
554
+ // was written at BOOT and nowhere else, so an NPC who was not
555
+ // opposition when the scene was built had no path to becoming it.
556
+ // - WHAT AN NPC MAY KNOW. Seven blocks of the brief were scene-globals
557
+ // handed to everybody. World events now carry WHERE they happened
558
+ // and Matrix-plane events reach only the Matrix (a bartender could
559
+ // read "the host MADE the intruder -- ice hunting"); ephemerals get
560
+ // no grapevine at all; the run's payout goes to the parties to the
561
+ // deal. The raw, unparsed model reply is no longer written into the
562
+ // NPC's memory -- only what actually reached the world is.
563
+ // - NOBODY SAYS IT TWICE. utilities/beat-echo.ts, the engine's first
564
+ // dedupe of emitted text of any kind. Containment rather than
565
+ // Jaccard, with a lower bar when a speaker opens the same way twice;
566
+ // companion link traffic is never judged (two drones clearing two
567
+ // rooms say near-identical things on purpose).
568
+ // - FACTIONS, catalog-backed (site v20, kind 'faction'). factions.ts
569
+ // holds the rules; the rows say who exists. First rule reading them:
570
+ // a Johnson will not lay out terms in front of a badge she can SEE.
571
+ // An unknown faction is no opinion, so a catalog without the rows
572
+ // behaves exactly as this engine did before them.
573
+ // - NEW CATALOG LANE `hearing`: hearingRangeMeters read the flat
574
+ // `perception` pool, so CYBEREYES extended how far you could hear.
575
+ // Audio Enhancement I-III and Spatial Recognizer are rows at last
576
+ // (p.445, p.454; Chrome Flesh p.223).
577
+ // - BENCH: repro schema 2.6.0 adds npcs[].hostile. A fixture could not
578
+ // express a hostile PERSON -- only `ice` -- so no meat-side
579
+ // hostility report could be benched at all.
580
+ export const ENGINE_VERSION = '1.58.0';
530
581
  //# sourceMappingURL=engine-version.js.map
@@ -0,0 +1,135 @@
1
+ import { factionFor } from './utilities/catalog.js';
2
+ /**
3
+ * THE SHADOW ECONOMY'S OWN SLUG. A Johnson, a fixer and a runner are
4
+ * not an organisation, but they are a SIDE -- the deniable one -- and
5
+ * the rules below need a name for it. Matches the catalog row.
6
+ */
7
+ export const SHADOWS = 'shadows';
8
+ /**
9
+ * WHAT FACTION IS THIS ACTOR, if the engine can tell.
10
+ *
11
+ * An explicit `faction` on the NPC wins, always -- a seed or a
12
+ * generator that says so is the author speaking. Failing that, the one
13
+ * thing the engine already knew about strangers is read: the ephemeral
14
+ * KIND that spawned them (utilities/ephemeral.ts). A sweep officer is
15
+ * an 'authority' body and was already, in every way that mattered, the
16
+ * law; this just gives the engine the word for it.
17
+ *
18
+ * DERIVING IS DELIBERATELY SHALLOW. It maps the three spawn archetypes
19
+ * the engine already has and stops. Guessing a faction from a name or a
20
+ * description would be the kind of inference that looks clever in a
21
+ * demo and puts a gang tattoo on a bartender in play.
22
+ */
23
+ export function factionOf(actor) {
24
+ const npc = actor;
25
+ if (npc.faction)
26
+ return npc.faction;
27
+ switch (npc.ephemeral?.kind) {
28
+ case 'authority': return 'knight-errant';
29
+ case 'mob': return 'ancients';
30
+ case 'bystander': return 'civilians';
31
+ default: return undefined;
32
+ }
33
+ }
34
+ /**
35
+ * HOW `a` REGARDS `b`: -3 (blood) .. +3 (family), 0 for no opinion.
36
+ *
37
+ * ASYMMETRIC BY DESIGN. A gang can hate a corp that has never heard of
38
+ * it, so this reads the standing of the faction doing the JUDGING and
39
+ * never averages the two. Where a faction is silent about another, the
40
+ * SORTS answer instead -- the law and the shadows are structurally
41
+ * opposed whether or not anybody wrote a row about it, and two gangs
42
+ * are rivals until someone says otherwise.
43
+ */
44
+ export function standingBetween(a, b) {
45
+ if (!a || !b)
46
+ return 0;
47
+ if (a === b)
48
+ return 3;
49
+ const rowA = factionFor(a);
50
+ const explicit = rowA?.standing?.[b];
51
+ if (typeof explicit === 'number')
52
+ return explicit;
53
+ // A DIVISION IS ITS PARENT'S. Knight Errant IS Ares Macrotechnology's
54
+ // security arm (p.38), so the two are one house unless a row says
55
+ // otherwise above -- and nobody writes "we like ourselves" into a
56
+ // standing table, which is exactly why this cannot be left to the
57
+ // declared numbers.
58
+ if (rowA?.parent === b)
59
+ return 3;
60
+ // And its opinion of a THIRD party is the house's, where it has not
61
+ // formed its own.
62
+ if (rowA?.parent) {
63
+ const inherited = factionFor(rowA.parent)?.standing?.[b];
64
+ if (typeof inherited === 'number')
65
+ return inherited;
66
+ }
67
+ const rowB = factionFor(b);
68
+ if (!rowA || !rowB)
69
+ return 0;
70
+ if (rowA.sort === 'law' && (rowB.sort === 'shadow' || rowB.sort === 'gang' || rowB.sort === 'syndicate'))
71
+ return -2;
72
+ if ((rowA.sort === 'shadow' || rowA.sort === 'gang' || rowA.sort === 'syndicate') && rowB.sort === 'law')
73
+ return -2;
74
+ if (rowA.sort === 'gang' && rowB.sort === 'gang')
75
+ return -1;
76
+ return 0;
77
+ }
78
+ /**
79
+ * WOULD BUSINESS BE DISCUSSED IN FRONT OF THIS ACTOR?
80
+ *
81
+ * The Johnson's question, and the one the reported session turned on.
82
+ * Three ways to be safe to talk in front of, and a stranger with no
83
+ * faction at all is one of them -- an unknown face in a bar is the
84
+ * normal condition of a bar, and treating every unplaceable body as a
85
+ * threat would mean no deal was ever struck anywhere.
86
+ */
87
+ export function wouldTalkBusinessNear(dealer, witness) {
88
+ const theirs = factionOf(witness);
89
+ if (!theirs)
90
+ return true;
91
+ const row = factionFor(theirs);
92
+ // A faction that keeps its mouth shut is not a problem to talk in
93
+ // front of, whatever it thinks of you: the syndicates and the gangs
94
+ // are IN the deniable economy, not auditing it.
95
+ if (row?.discreet)
96
+ return true;
97
+ return standingBetween(dealer ?? SHADOWS, theirs) > -2;
98
+ }
99
+ /**
100
+ * THE FIRST BODY IN THIS ROOM THE DEAL CANNOT BE STRUCK IN FRONT OF,
101
+ * or undefined when the room will do.
102
+ *
103
+ * Lives here rather than in game.ts so it can be tested against a
104
+ * catalog the way every other faction rule is -- and because "who
105
+ * spoils a deal" is a faction question, not a Game one.
106
+ *
107
+ * SIGHT IS THE TEST, NOT EARSHOT, and getting that backwards was the
108
+ * first thing tried. Earshot is wrong twice over: a badge who cannot
109
+ * make out the WORDS can still make out the MEETING, and canHearSpeech
110
+ * deliberately refuses an unplaced listener -- which is precisely the
111
+ * patrol officer WORKING the room, the exact witness the report is
112
+ * about. The reporter's own words are about presence: "there wouldn't
113
+ * be a 'meet' if there was a police officer STANDING THERE."
114
+ *
115
+ * @param sees the engine's own "can this observer see that actor"
116
+ * (utilities/spots.ts wouldNotice), passed in so this
117
+ * module stays free of the spatial layer.
118
+ */
119
+ export function businessWitness(client, others, sees) {
120
+ const dealer = factionOf(client) ?? SHADOWS;
121
+ for (const other of others) {
122
+ if (other === client)
123
+ continue;
124
+ if (other.isIncapacitated?.())
125
+ continue;
126
+ if (!client.canPerceive(other))
127
+ continue;
128
+ if (!sees(client, other))
129
+ continue;
130
+ if (!wouldTalkBusinessNear(dealer, other))
131
+ return other;
132
+ }
133
+ return undefined;
134
+ }
135
+ //# sourceMappingURL=factions.js.map
@@ -162,6 +162,23 @@ export function buildReproScene(fixture) {
162
162
  throw new ReproFixtureError(`"${misplacedIce.name}" is ice, so it cannot also stand in a doorway, guard, circulate or carry a gun. `
163
163
  + `Ice lives in the host, not on the floor -- drop the meat-side flags, or drop "ice".`);
164
164
  }
165
+ // HOSTILITY SAID TWICE IS DECORATION ONCE (aCErAfvEW7G23FWDM). Ice is
166
+ // opposition BY CONSTRUCTION -- scene-factory makes a matrix-plane
167
+ // actor hostile unless the seed says otherwise -- so a fixture
168
+ // declaring both is asserting the flag rather than the rule, which is
169
+ // the objection the `ice` comment in repro.ts raises and is right
170
+ // about. A street doc who opens fire on the patient is not a fixture
171
+ // either; it is two fixtures wearing one name.
172
+ const hostileIce = npcs.find(n => n.hostile && n.ice);
173
+ if (hostileIce) {
174
+ throw new ReproFixtureError(`"${hostileIce.name}" is asked to be both ice and hostile. Ice is opposition by construction -- `
175
+ + `drop "hostile", or drop "ice" if you meant a hostile person.`);
176
+ }
177
+ const hostileDoc = npcs.find(n => n.hostile && n.doc);
178
+ if (hostileDoc) {
179
+ throw new ReproFixtureError(`"${hostileDoc.name}" is asked to be both a street doc and hostile. A doc who opens fire on the `
180
+ + `patient is two fixtures -- split them, or pick one.`);
181
+ }
165
182
  // A SEED HAS ONE PLANE. astral maps to 'astral' the same way ice maps
166
183
  // to 'matrix' (npc-seed.ts) -- asking for both is asking the builder
167
184
  // to pick a lie.
@@ -379,6 +396,13 @@ export function buildReproScene(fixture) {
379
396
  // string, which would read as a spot whose name is nothing.
380
397
  ...(npc.circulating ? {} : { startSpot: inDoorway ? REPRO_DOORWAY_SPOT : REPRO_FLOOR_SPOT }),
381
398
  guarding: npc.guarding === true,
399
+ // OPPOSITION, IN THE MEAT (aCErAfvEW7G23FWDM). Straight onto
400
+ // the seed's own `hostile`, which scene-factory reads and the
401
+ // hostile-contact ladder then drives by itself -- notice,
402
+ // contact, provocation, patience. Emitted only when asked, so
403
+ // a fixture that says nothing builds the same peaceable NPC it
404
+ // always did.
405
+ ...(npc.hostile ? { hostile: true } : {}),
382
406
  lifestyleChoices: [],
383
407
  combat: {
384
408
  body: 4, agility: 4, reaction: 4, combatSkill: 3, strength: 4,