@maka/maka-cli 5.197.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 (54) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +6 -6
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +3 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +25 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +25 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -3
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +3 -3
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +2 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +5 -5
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +3 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +49 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +8 -8
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +2 -2
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +3 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +3 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +5 -5
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +3 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +3 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +19 -3
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +2 -2
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/shout.js +69 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/snoop.js +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +3 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +2 -2
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +7 -7
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +75 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/factions.js +135 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +24 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/game.js +66 -5
  38. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +165 -14
  39. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +90 -57
  40. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +84 -8
  41. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/beat-echo.js +160 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +25 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +153 -10
  46. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +14 -14
  48. package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +48 -2
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-style.js +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +235 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +122 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +2 -2
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
  54. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.197.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.",
@@ -64,14 +64,14 @@ export class ArCommand extends Command {
64
64
  return `Overlay back on -- over nothing. No commlink, no deck, no AR: you're reading the street with your eyes like it's 2050.`;
65
65
  }
66
66
  const tags = aroTags(this.scene, actor, actor.currentLocation);
67
- const head = `{lightblue-fg}The overlay blinks back in.{/lightblue-fg}`;
67
+ const head = `{light-blue-fg}The overlay blinks back in.{/light-blue-fg}`;
68
68
  // Turning AR back on while silent is the other moment the question
69
69
  // lands -- you have just done the thing you were unsure was allowed.
70
70
  const note = silentAndSeeingNote(actor);
71
71
  if (tags.length === 0) {
72
- return [head, `{lightblue-fg}Nothing here is talking -- a dead patch of street.{/lightblue-fg}`, note].filter(Boolean).join('\n');
72
+ return [head, `{light-blue-fg}Nothing here is talking -- a dead patch of street.{/light-blue-fg}`, note].filter(Boolean).join('\n');
73
73
  }
74
- return [head, ...tags.map(t => ` {lightblue-fg}${t}{/lightblue-fg}`), note].filter(Boolean).join('\n');
74
+ return [head, ...tags.map(t => ` {light-blue-fg}${t}{/light-blue-fg}`), note].filter(Boolean).join('\n');
75
75
  }
76
76
  }
77
77
  /**
@@ -105,11 +105,11 @@ export class ArosCommand extends Command {
105
105
  const note = silentAndSeeingNote(actor);
106
106
  const tags = aroTags(this.scene, actor, actor.currentLocation);
107
107
  if (tags.length === 0) {
108
- return [`{lightblue-fg}Nothing here is talking -- a dead patch of street.{/lightblue-fg}`, note].filter(Boolean).join('\n');
108
+ return [`{light-blue-fg}Nothing here is talking -- a dead patch of street.{/light-blue-fg}`, note].filter(Boolean).join('\n');
109
109
  }
110
110
  return [
111
- `{lightblue-fg}AR OVERLAY:{/lightblue-fg}`,
112
- ...tags.map(t => ` {lightblue-fg}${t}{/lightblue-fg}`),
111
+ `{light-blue-fg}AR OVERLAY:{/light-blue-fg}`,
112
+ ...tags.map(t => ` {light-blue-fg}${t}{/light-blue-fg}`),
113
113
  note,
114
114
  ].filter(Boolean).join('\n');
115
115
  }
@@ -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
  }
@@ -92,7 +92,7 @@ export class DownloadCommand extends Command {
92
92
  // is that nobody could find the file should say what it can see.
93
93
  const query = args.join(' ').trim();
94
94
  if (query.length === 0) {
95
- const lines = [`{lightblue-fg}Within reach to pull down:{/lightblue-fg}`];
95
+ const lines = [`{light-blue-fg}Within reach to pull down:{/light-blue-fg}`];
96
96
  for (const i of loose)
97
97
  lines.push(` ${planeTintItemName(i, actor.plane)} -- loose on the grid here`);
98
98
  for (const c of carried)
@@ -140,11 +140,11 @@ export class DownloadCommand extends Command {
140
140
  this.scene.updateStatus();
141
141
  return fromSprite
142
142
  ? [
143
- `{lightblue-fg}${fromSprite.npc.name} opens its hands and the data pours out of them.{/lightblue-fg}`,
143
+ `{light-blue-fg}${fromSprite.npc.name} opens its hands and the data pours out of them.{/light-blue-fg}`,
144
144
  `You pull {bold}{underline}${item.name}{/underline}{/bold} down onto a chip -- meat-world cargo now.${hint(` (You can "give ${item.name}" to somebody standing in front of you.)`)}`,
145
145
  ].join('\n')
146
146
  : [
147
- `{lightblue-fg}You pull {bold}{underline}${item.name}{/underline}{/bold} down off the grid -- written to a chip.{/lightblue-fg}`,
147
+ `{light-blue-fg}You pull {bold}{underline}${item.name}{/underline}{/bold} down off the grid -- written to a chip.{/light-blue-fg}`,
148
148
  hint(`(Meat-world cargo now: carry it, stash it, or "give" it to somebody.)`),
149
149
  ].join('\n');
150
150
  }
@@ -111,11 +111,11 @@ 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 [
118
- `{lightblue-fg}${file.name} comes apart under your hand -- sectors zeroed, index entry gone, the host's own checksum rewritten to agree. ${hostLabel(room, { capital: true })} no longer has it.{/lightblue-fg}`,
118
+ `{light-blue-fg}${file.name} comes apart under your hand -- sectors zeroed, index entry gone, the host's own checksum rewritten to agree. ${hostLabel(room, { capital: true })} no longer has it.{/light-blue-fg}`,
119
119
  ...(banner ? [banner] : []),
120
120
  ].join('\n');
121
121
  }
@@ -64,7 +64,7 @@ export class EnterHostCommand extends Command {
64
64
  const marks = target.hostMarksBy.get(actor.name) ?? 0;
65
65
  if (marks < 1 && !target.hostCracked && !target.hostSanctioned) {
66
66
  return [
67
- `{lightblue-fg}${hostLabel(target, { capital: true })} doesn't know you. Its architecture has no door shaped like your icon.{/lightblue-fg}`,
67
+ `{light-blue-fg}${hostLabel(target, { capital: true })} doesn't know you. Its architecture has no door shaped like your icon.{/light-blue-fg}`,
68
68
  hint(`A host opens to anyone holding a MARK on it (p.239) -- "mark ${target.name}" softens it, or "hack" takes one by force.`),
69
69
  ].join('\n');
70
70
  }
@@ -85,7 +85,7 @@ export class EnterHostCommand extends Command {
85
85
  // ONE LINE (2026-09-13, "too much text"): the crossing, and the
86
86
  // coaching behind the hints toggle.
87
87
  return [
88
- `{lightblue-fg}You cross into ${hostLabel(target, { capital: false })} -- the outside grid folds shut behind you.{/lightblue-fg}`,
88
+ `{light-blue-fg}You cross into ${hostLabel(target, { capital: false })} -- the outside grid folds shut behind you.{/light-blue-fg}`,
89
89
  hint(`(In here there is only what the host keeps. "look" reads it; "exit" puts you back on the grid.)`),
90
90
  ].filter(Boolean).join('\n');
91
91
  }
@@ -110,14 +110,14 @@ export class EnterHostCommand extends Command {
110
110
  const over = hostOver(gridVicinity(this.actor)) ?? hostOver(here);
111
111
  if (over)
112
112
  return over;
113
- return `{lightblue-fg}Thin grid here -- no host stands over ${gridVicinity(this.actor).name}.{/lightblue-fg} ${hint(`Overhead: ${roster} -- "enter <name>" crosses into any of them from here.`)}`;
113
+ return `{light-blue-fg}Thin grid here -- no host stands over ${gridVicinity(this.actor).name}.{/light-blue-fg} ${hint(`Overhead: ${roster} -- "enter <name>" crosses into any of them from here.`)}`;
114
114
  }
115
115
  const host = resolveHostInReach(rooms, this.actor, named);
116
116
  if (host)
117
117
  return host;
118
118
  const street = Object.values(rooms).find(r => r.name.toLowerCase() === named.toLowerCase());
119
119
  if (street)
120
- return `{lightblue-fg}${street.name} has no host over it -- nothing to enter. Overhead: ${roster}.{/lightblue-fg}`;
120
+ return `{light-blue-fg}${street.name} has no host over it -- nothing to enter. Overhead: ${roster}.{/light-blue-fg}`;
121
121
  return `No host called "${named}" overhead. ${hint(`Overhead: ${roster}.`)}`;
122
122
  }
123
123
  }
@@ -152,7 +152,7 @@ export class ExitHostCommand extends Command {
152
152
  actor.performAction('exits a host', `icon surfacing out of ${host.name}'s architecture`, { quiet: this.scene.isHumanControlled(actor) });
153
153
  this.logger.write(`${actor.name} exited the host at ${host.name}.`);
154
154
  this.scene.updateStatus();
155
- return `{lightblue-fg}You step back out of ${hostLabel(host, { capital: false })} onto the grid; the host seals behind you. Your body still sits in ${actor.currentLocation.name}.{/lightblue-fg}`;
155
+ return `{light-blue-fg}You step back out of ${hostLabel(host, { capital: false })} onto the grid; the host seals behind you. Your body still sits in ${actor.currentLocation.name}.{/light-blue-fg}`;
156
156
  }
157
157
  }
158
158
  //# sourceMappingURL=enter-host.js.map
@@ -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.`,
@@ -317,14 +317,14 @@ export class HackCommand extends BypassCommand {
317
317
  if (marksNow >= 1)
318
318
  lines.push(` One mark is the door: "enter" walks your persona inside (p.239).`);
319
319
  if (perception > 0)
320
- lines.push(` {lightblue-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what this node is (p.240).{/lightblue-fg}`);
320
+ lines.push(` {light-blue-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what this node is (p.240).{/light-blue-fg}`);
321
321
  if (forcedDv > 0)
322
322
  lines.push(` The forcing burns ${forcedDv} DV into the architecture -- a host has no boards to brick, so it only announces you louder.`);
323
323
  lines.push(` ${MAX_MARKS - marksNow} more and its architecture stops asking who you are.${hint(` ("hack for ${MAX_MARKS - marksNow}" reaches for the rest at once -- ${declarationPenalty(MAX_MARKS - marksNow, this.actor.hasQuality(GO_BIG_QUALITY))} dice.)`)}`);
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();
@@ -388,13 +388,13 @@ export class HackCommand extends BypassCommand {
388
388
  lines.push(` ${iconLocks.length} ${iconLocks.length === 1 ? 'lock hangs' : 'locks hang'} off it, still sealed -- each its own icon behind its own rating: ${iconLocks.map(d => d.name).join(', ')}.${hint(` ("hack <lock>" from in here keys one; the host's key is not the door's.)`)}`);
389
389
  }
390
390
  if (vaultedFiles.length > 0) {
391
- lines.push(` {lightblue-fg}The data vault unseals -- ${vaultedFiles.map(i => i.name).join(', ')} spill${vaultedFiles.length === 1 ? 's' : ''} into the open.${hint(` Yours to "take".`)}{/lightblue-fg}`);
391
+ lines.push(` {light-blue-fg}The data vault unseals -- ${vaultedFiles.map(i => i.name).join(', ')} spill${vaultedFiles.length === 1 ? 's' : ''} into the open.${hint(` Yours to "take".`)}{/light-blue-fg}`);
392
392
  }
393
393
  // AND WHAT THE CRACK DID NOT REACH: a wired site's data sits on an
394
394
  // air-gapped box the host never held (p.233) -- say so, or the next
395
395
  // "take" reads as a contradiction.
396
396
  if (room.offlineServer && room.inventory.getAllItems().some(i => i.plane === 'matrix')) {
397
- lines.push(` {lightblue-fg}Nothing on the grid side of the vault -- whatever this place keeps sits on the ${room.offlineServer}, an air-gapped box with no Matrix presence. Your BODY has to be in ${room.name} to cable in (p.233).{/lightblue-fg}`);
397
+ lines.push(` {light-blue-fg}Nothing on the grid side of the vault -- whatever this place keeps sits on the ${room.offlineServer}, an air-gapped box with no Matrix presence. Your BODY has to be in ${room.name} to cable in (p.233).{/light-blue-fg}`);
398
398
  }
399
399
  // THE FEEDS ARE A REAL GAIN AND WERE NEVER MENTIONED
400
400
  // (BpR5cjhYuvgCyDSZc, 7aNWnDSHeZMYmS2De: "what have I gained?").
@@ -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
  }
@@ -683,7 +683,7 @@ export class HackCommand extends BypassCommand {
683
683
  else {
684
684
  const perception = freeMatrixPerceptionHits(netHits);
685
685
  if (perception > 0)
686
- lines.push(` {lightblue-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what it guards (p.240).{/lightblue-fg}`);
686
+ lines.push(` {light-blue-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what it guards (p.240).{/light-blue-fg}`);
687
687
  }
688
688
  // TWO MARKS COMMAND IT (p.238 Control Device: 1 mark for a Free
689
689
  // action, 2 for a Simple, 3 for a Complex -- and unlocking a maglock
@@ -101,7 +101,7 @@ export class HideCommand extends Command {
101
101
  this.scene.addWorldEvent(`${host.name}'s ice lost ${actor.name} in the traffic.`);
102
102
  this.scene.updateStatus();
103
103
  return [
104
- `{lightblue-fg}You drop into the host's own traffic and become one more packet in a million. ${hostLabel(room, { capital: true })} loses you.{/lightblue-fg}`,
104
+ `{light-blue-fg}You drop into the host's own traffic and become one more packet in a million. ${hostLabel(room, { capital: true })} loses you.{/light-blue-fg}`,
105
105
  ` Its ice has nothing to swing at -- but the host is still awake, and Patrol is looking every action now.${hint(` ("silent" keeps a sweep from seeing you for free; "search" is possible while it looks.)`)}`,
106
106
  ...god.lines,
107
107
  ].join('\n');
@@ -71,7 +71,7 @@ export class HopCommand extends Command {
71
71
  actor.performAction('hops grids', `persona blinking across to ${target.name}`);
72
72
  this.logger.write(`${actor.name} hopped to ${target.key} (legal, ${tier}).`);
73
73
  this.scene.updateStatus();
74
- return `{lightblue-fg}The sky changes texture and you're on ${target.name}.{/lightblue-fg}${target.userPenalty ? ` ${hint(`Public grid: -2 on everything you do out here.`)}` : ''}${await land()}`;
74
+ return `{light-blue-fg}The sky changes texture and you're on ${target.name}.{/light-blue-fg}${target.userPenalty ? ` ${hint(`Public grid: -2 on everything you do out here.`)}` : ''}${await land()}`;
75
75
  }
76
76
  // ILLEGAL HOP (p.240): Hack on the Fly against the grid itself.
77
77
  const burned = actor.burnedAttributeRefusal('sleaze');
@@ -105,7 +105,7 @@ export class HopCommand extends Command {
105
105
  this.logger.write(`${actor.name} hopped to ${target.key} (illegal, ${attempt.hits} v ${defence.hits}).`);
106
106
  this.scene.updateStatus();
107
107
  return [
108
- `{lightblue-fg}You slip through a seam nobody sold you and come up on ${target.name}.{/lightblue-fg}`,
108
+ `{light-blue-fg}You slip through a seam nobody sold you and come up on ${target.name}.{/light-blue-fg}`,
109
109
  ...bill.lines,
110
110
  ...(isLooseEnd ? [await land()] : []),
111
111
  ].join('\n');
@@ -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');
@@ -387,7 +387,7 @@ export class LookCommand extends Command {
387
387
  const dim = this.scene.getActorsInRoom(far)
388
388
  .find(a => a !== this.actor && a.name.toLowerCase() === target.toLowerCase() && this.actor.canPerceive(a));
389
389
  if (dim)
390
- return `${this.inspectPersona(dim)}\n{lightblue-fg} -- a far icon, ${far.name}${noiseNote(noise)}{/lightblue-fg}`;
390
+ return `${this.inspectPersona(dim)}\n{light-blue-fg} -- a far icon, ${far.name}${noiseNote(noise)}{/light-blue-fg}`;
391
391
  }
392
392
  }
393
393
  // 3. THE HOST, as a thing you can name. gridIcons already renders it
@@ -404,8 +404,8 @@ export class LookCommand extends Command {
404
404
  // `deck` reads out your own array.
405
405
  const a = namedHost.hostAttributes();
406
406
  return [
407
- `{lightblue-fg}${hostIcon}{/lightblue-fg}`,
408
- `{lightblue-fg} Attack ${a.attack} Sleaze ${a.sleaze} Data Processing ${a.dataProcessing} Firewall ${a.firewall}{/lightblue-fg}`,
407
+ `{light-blue-fg}${hostIcon}{/light-blue-fg}`,
408
+ `{light-blue-fg} Attack ${a.attack} Sleaze ${a.sleaze} Data Processing ${a.dataProcessing} Firewall ${a.firewall}{/light-blue-fg}`,
409
409
  ].join('\n');
410
410
  }
411
411
  }
@@ -417,7 +417,7 @@ export class LookCommand extends Command {
417
417
  if (matchesCameraName(target)) {
418
418
  const eye = gridIcons(this.scene, this.actor, room).find(i => i.startsWith('◎'));
419
419
  if (eye)
420
- return `{lightblue-fg}${eye}{/lightblue-fg}`;
420
+ return `{light-blue-fg}${eye}{/light-blue-fg}`;
421
421
  // Named correctly, and there is simply nothing here to name.
422
422
  // Worth its own line: the old answer said the WORDS were wrong,
423
423
  // which sent the player hunting for a synonym that never
@@ -533,7 +533,7 @@ export class LookCommand extends Command {
533
533
  * there's a deck anywhere in the shape of it.
534
534
  */
535
535
  inspectPersona(target) {
536
- const lines = [`{lightblue-fg}◆ ${target.name} -- a persona on this grid.{/lightblue-fg}`];
536
+ const lines = [`{light-blue-fg}◆ ${target.name} -- a persona on this grid.{/light-blue-fg}`];
537
537
  const companion = this.scene.ownerGame?.companions.find(c => c.npc === target);
538
538
  if (companion) {
539
539
  lines.push(`Yours -- it goes where you go, and it answers when you speak.`);
@@ -93,7 +93,7 @@ export class MapCommand extends Command {
93
93
  actor.insideHost
94
94
  ? `THE GRID -- INSIDE ${hostLabel(actor.insideHost, { capital: true }).toUpperCase()}`
95
95
  : `THE GRID -- ${actor.currentGrid ? `on ${actor.currentGrid.name}, ` : ''}jacked in from ${body.name}`,
96
- `{lightblue-fg}Black flatland under a black sky. No streets out here -- only icons. The hosts hang overhead; ${actor.insideHost ? '"exit" puts you back under them' : '"go <host>" (or "go <number>") crosses into one you hold a mark on, from anywhere'}.{/lightblue-fg}`,
96
+ `{light-blue-fg}Black flatland under a black sky. No streets out here -- only icons. The hosts hang overhead; ${actor.insideHost ? '"exit" puts you back under them' : '"go <host>" (or "go <number>") crosses into one you hold a mark on, from anywhere'}.{/light-blue-fg}`,
97
97
  ];
98
98
  if (hosts.length === 0) {
99
99
  lines.push(' Nothing overhead -- no host stands over this district.');
@@ -51,7 +51,7 @@ export class MarkCommand extends Command {
51
51
  screen: this.screen,
52
52
  game: this.game,
53
53
  }).execute(args);
54
- return `{lightblue-fg}Placing a mark IS ${action} -- running it (p.238/p.240).{/lightblue-fg}\n${result}`;
54
+ return `{light-blue-fg}Placing a mark IS ${action} -- running it (p.238/p.240).{/light-blue-fg}\n${result}`;
55
55
  }
56
56
  }
57
57
  //# sourceMappingURL=mark.js.map
@@ -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;
@@ -87,6 +87,7 @@ export class SayCommand extends Command {
87
87
  }
88
88
  const message = args?.join(' ');
89
89
  const currentPlayer = this.actor;
90
+ let farOff = "";
90
91
  const scene = this.context.scene;
91
92
  // Speaking out loud ends a sneak (see sneak.ts).
92
93
  currentPlayer.sneaking = false;
@@ -245,8 +246,23 @@ export class SayCommand extends Command {
245
246
  // (matrix-roster.ts); a body's reach the room, as always.
246
247
  if (currentPlayer.plane === 'matrix')
247
248
  speakOnTheGrid(this.scene, currentPlayer, message);
248
- else
249
- currentPlayer.currentLocation.say(currentPlayer, message);
249
+ else {
250
+ const heard = currentPlayer.currentLocation.say(currentPlayer, message);
251
+ // NOBODY CLOSE ENOUGH is now a thing that can happen, and has to
252
+ // be said (en3p6eCpiRuqsTSKm). Speech used to escalate to a
253
+ // room-wide shout the moment nobody shared your spot, so this
254
+ // case did not exist; with `say` bounded by each listener's
255
+ // hearing range it does, and a player whose words reached nobody
256
+ // would otherwise see their own echo and assume they landed.
257
+ //
258
+ // Only when there was somebody TO miss: an empty room is not a
259
+ // failure to be heard, it is an empty room, and nagging about it
260
+ // every time a runner mutters to themselves would be noise.
261
+ const others = currentPlayer.currentLocation.getActors().filter(a => a !== currentPlayer);
262
+ if ((heard ?? []).length === 0 && others.length > 0 && !(this.actor instanceof NPC)) {
263
+ farOff = `\n Nobody's close enough to catch that.${hint(` ("shout" carries the whole room; "move to <place>" gets you near someone.)`)}`;
264
+ }
265
+ }
250
266
  // Talking in the CLIENT's presence is the meet (a real session
251
267
  // walked up to Ms. Veil, spoke with plain "say", and the advance
252
268
  // never triggered -- only talk-to/call/give were hooked). The
@@ -294,7 +310,7 @@ export class SayCommand extends Command {
294
310
  ? `Your persona speaks and the grid carries it: ${heard.join(', ')} ${heard.length === 1 ? 'is' : 'are'} on this side of the architecture and hears you. Bodies in the room hear nothing -- you have no voice out there.`
295
311
  : `Your words go out over the grid and find no persona to land on. Anyone here in the FLESH hears nothing -- a PAN icon is a commlink in somebody's pocket, not a person you can talk to. ("call <name>" reaches a body.)`;
296
312
  }
297
- return "";
313
+ return farOff;
298
314
  }
299
315
  /**
300
316
  * "say private" -- the answer to "can anyone hear me right now",