@maka/maka-cli 5.199.0 → 5.201.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 (39) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +10 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +2 -2
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +7 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +7 -6
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +10 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +20 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +237 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +20 -11
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +3 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +3 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +3 -2
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +15 -5
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/overwatch.js +3 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +36 -10
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/sprite-power.js +85 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +12 -3
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +3 -2
  19. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +139 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/game.js +115 -9
  21. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +19 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +8 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +44 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.js +48 -7
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-errand.js +67 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-hire.js +113 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/drone-prose.js +11 -3
  29. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +42 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +10 -5
  31. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +37 -10
  32. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +12 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +22 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/utilities/mechanics-audience.js +71 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +7 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/presence-gate.js +27 -2
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +7 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/utilities/sprite-power-actions.js +270 -0
  39. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.199.0",
3
+ "version": "5.201.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.",
@@ -7,6 +7,7 @@ import { FIXER_NAME, isFixerName } from '../factories/scene-seed-generator.js';
7
7
  import { sameSpot, spotOf, spotRefusal } from '../utilities/spots.js';
8
8
  import { catalogGear } from '../utilities/catalog.js';
9
9
  import { PAPER_AVAILABILITY } from '../utilities/sin.js';
10
+ import { cleanupQuoteFor } from '../utilities/cleanup-hire.js';
10
11
  /**
11
12
  * Legwork (SR5e p.387-388): "ask <contact> about <thing>" taps a
12
13
  * CONTACT's network for real information -- in person or over a live
@@ -105,6 +106,15 @@ export class AskCommand extends Command {
105
106
  if (/\b(work|gig|errand|hiring|delivery)\b/i.test(topic)) {
106
107
  return this.offerWork(target, contact);
107
108
  }
109
+ // "ask <contact> about cleanup" is a PRICE, not a network pull: no
110
+ // dice and no per-homecoming budget spent. A runner who cannot reach
111
+ // the Matrix buys the scrub instead of running it
112
+ // (utilities/cleanup-errand.ts, user 2026-09-15).
113
+ if (/\b(clean-?up|footage|feeds?|scrub|cameras?)\b/i.test(topic) && this.game) {
114
+ const quote = cleanupQuoteFor(this.game, target.name, topic.replace(/\b(clean-?up|footage|feeds?|scrub|cameras?|the|at|on|about)\b/gi, '').trim());
115
+ if (quote)
116
+ return quote;
117
+ }
108
118
  if (contact.asked) {
109
119
  return `${target.name} already worked their network for you since you last went out -- nothing new has come in.${hint(` (Legwork refreshes when you come home from a job.)`)}`;
110
120
  }
@@ -27,13 +27,13 @@ export const SPRITE_TYPES = {
27
27
  name: 'Fault',
28
28
  blurb: 'the attack sprite -- a jagged error in the world\'s code that hunts ice',
29
29
  combat: l => ({ logic: l + 1, intuition: l, willpower: l + 1, reaction: l + 1, body: 1, agility: 1, strength: 1, charisma: 1,
30
- skills: { hacking: l + 1, perception: l } }),
30
+ skills: { hacking: l + 1, perception: l, cybercombat: l + 1 } }),
31
31
  },
32
32
  machine: {
33
33
  name: 'Machine',
34
34
  blurb: 'the device whisperer -- drones, locks, and cameras answer it',
35
35
  combat: l => ({ logic: l + 2, intuition: l, willpower: l, reaction: l + 1, body: 1, agility: 1, strength: 1, charisma: 1,
36
- skills: { hacking: l, perception: l + 1 } }),
36
+ skills: { hacking: l, perception: l + 1, hardware: l } }),
37
37
  },
38
38
  };
39
39
  /**
@@ -310,6 +310,13 @@ export class HireCommand extends Command {
310
310
  // Mechanics panel (ui.ts MENU_VERBS).
311
311
  if (args.length === 0)
312
312
  return `Hire whom? "crew hire" shows the board.`;
313
+ // "hire cleanup [<site>]" IS NOT A CREW SEAT (user 2026-09-15). It
314
+ // buys a shadow decker to scrub a loose end for a runner who cannot
315
+ // reach the Matrix themselves -- see utilities/cleanup-errand.ts.
316
+ if (/^clean-?up$/i.test(args[0])) {
317
+ const { hireCleanupErrand } = await import('../utilities/cleanup-hire.js');
318
+ return hireCleanupErrand(this.actor, game, args.slice(1).join(' '), this.logger);
319
+ }
313
320
  // ONE TABLE AT A TIME (playtest note): riding another leader's
314
321
  // table means their jobs are your jobs -- no building your own
315
322
  // crew underneath it. "crew leave" first.
@@ -13,6 +13,7 @@ import { gridActionModifier, gridNote } from '../utilities/grids.js';
13
13
  import { billAction } from '../utilities/action-cost.js';
14
14
  import { MAX_MARKS } from '../utilities/marks.js';
15
15
  import { sameHostSide } from '../models/player.js';
16
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
16
17
  /** The marks Control Device wants for a Simple device action (p.238). */
17
18
  export const MARKS_TO_CONTROL = 2;
18
19
  export class DisableCommand extends Command {
@@ -184,8 +185,8 @@ export class DisableCommand extends Command {
184
185
  const defenceRoll = rollPool(defence.pool);
185
186
  const net = roll.hits - defenceRoll.hits;
186
187
  actor.performAction('takes control of a device', name, { quiet: this.scene.isHumanControlled(actor) });
187
- if (this.scene.isHumanControlled(actor)) {
188
- this.logger.meta(`Control Device (Electronic Warfare + Intuition [Sleaze])${gridNote(grid)}: ${formatRoll(roll)}`);
188
+ if (showsMechanics(this.scene, actor)) {
189
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Control Device (Electronic Warfare + Intuition [Sleaze])${gridNote(grid)}: ${formatRoll(roll)}`);
189
190
  this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
190
191
  }
191
192
  const godLines = this.godBill(defenceRoll.hits, 'Control Device (sleaze)');
@@ -240,8 +241,8 @@ export class DisableCommand extends Command {
240
241
  const defenceRoll = rollPool(defence.pool);
241
242
  const net = roll.hits - defenceRoll.hits;
242
243
  actor.performAction('throws a Data Spike', name, { quiet: this.scene.isHumanControlled(actor) });
243
- if (this.scene.isHumanControlled(actor)) {
244
- this.logger.meta(`Data Spike (Cybercombat + Logic [Attack ${limit}])${gridNote(grid)}: ${formatRoll(roll)}`);
244
+ if (showsMechanics(this.scene, actor)) {
245
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Data Spike (Cybercombat + Logic [Attack ${limit}])${gridNote(grid)}: ${formatRoll(roll)}`);
245
246
  this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
246
247
  }
247
248
  const godLines = this.godBill(defenceRoll.hits, 'Data Spike (attack)');
@@ -272,8 +273,8 @@ export class DisableCommand extends Command {
272
273
  const dv = limit + net + 2 * marks;
273
274
  const soak = rollPool(this.defence(t).pool);
274
275
  const dealt = Math.max(0, dv - soak.hits);
275
- if (this.scene.isHumanControlled(actor)) {
276
- this.logger.meta(`Data Spike -- ${dv} DV (Attack ${limit} +${net} net${marks > 0 ? ` +${2 * marks} for ${marks} mark${marks === 1 ? '' : 's'}` : ''}) v. ${defence.label}: ${formatRoll(soak)} -> ${dealt}`);
276
+ if (showsMechanics(this.scene, actor)) {
277
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Data Spike -- ${dv} DV (Attack ${limit} +${net} net${marks > 0 ? ` +${2 * marks} for ${marks} mark${marks === 1 ? '' : 's'}` : ''}) v. ${defence.label}: ${formatRoll(soak)} -> ${dealt}`);
277
278
  }
278
279
  // An Attack action is never subtle: the owner knows.
279
280
  this.wakeOwner(t, false);
@@ -3,6 +3,7 @@ import { Door } from '../models/door.js';
3
3
  import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { ownedDrones } from '../utilities/owned-drones.js';
5
5
  import { droneReconLaunchLine, droneReconNowhereLine, droneReconReturnLine, droneSlipsVents } from '../utilities/drone-prose.js';
6
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
6
7
  /**
7
8
  * The rigger's mechanic: launch a carried surveillance drone for a live
8
9
  * sweep of every room adjacent to this one -- who's there, what's locked,
@@ -42,13 +43,20 @@ export class DroneCommand extends Command {
42
43
  // sweep comes back as static -- relaunch and try again.
43
44
  const piloting = actor.skillRating('piloting');
44
45
  const roll = rollPool(Math.max(1, actor.reaction + (piloting > 0 ? piloting : -1) + actor.bonus('piloting') + actor.woundModifier - actor.sustainingPenalty));
46
+ // DICE GO TO THE MECHANICS PANEL, like every other test in the engine
47
+ // (wjFBY3zgyWzJP9adw sweep). This one was written into the prose line
48
+ // instead -- the only player roll a rigger had to read out of a
49
+ // sentence.
50
+ if (showsMechanics(this.scene, actor)) {
51
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Piloting (recon sweep): ${formatRoll(roll)}`);
52
+ }
45
53
  if (roll.hits === 0) {
46
54
  return [
47
- droneReconLaunchLine(drone, formatRoll(roll), false),
55
+ droneReconLaunchLine(drone, false),
48
56
  ` It clips a railing, spins, and the feed dissolves into static. It limps home with nothing.`,
49
57
  ].join('\n');
50
58
  }
51
- const lines = [droneReconLaunchLine(drone, formatRoll(roll), true)];
59
+ const lines = [droneReconLaunchLine(drone, true)];
52
60
  for (const [direction, exit] of exits) {
53
61
  let target;
54
62
  let lockNote = '';
@@ -5,6 +5,7 @@ import { billAction } from '../utilities/action-cost.js';
5
5
  import { hostDefensePool } from '../utilities/matrix-intrusion.js';
6
6
  import { hostLabel } from '../utilities/grid-names.js';
7
7
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
8
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
8
9
  /**
9
10
  * EDIT FILE (SR5 p.239, RAG-checked 2026-09-14): a Complex Action, Marks
10
11
  * Required 1, Computer + Logic [Data Processing] v. Intuition + Firewall.
@@ -50,6 +51,17 @@ export class EditFileCommand extends Command {
50
51
  }
51
52
  async execute(args = []) {
52
53
  const actor = this.actor;
54
+ // "erase mark[s] on <icon>" IS A DIFFERENT ACTION (f79LLqTerep4nKWcA).
55
+ // Edit File deletes a FILE; Erase Mark (p.239) scrubs a recognition
56
+ // key, and they share only the English word. The player who types
57
+ // "erase mark" means the second one, so route it rather than
58
+ // answering "no file called mark".
59
+ if (/^marks?$/i.test(args[0] ?? '') || /^marks?$/i.test(args[1] ?? '')) {
60
+ const { EraseMarkCommand } = await import('./erase-mark.js');
61
+ return new EraseMarkCommand({
62
+ actor: this.actor, scene: this.scene, rooms: this.rooms, screen: this.screen, game: this.game,
63
+ }).execute(args);
64
+ }
53
65
  const { op, word, rest } = this.operation(args);
54
66
  if (op !== 'delete') {
55
67
  const named = word ? `"${word}"` : 'that';
@@ -79,6 +91,12 @@ export class EditFileCommand extends Command {
79
91
  if (!file) {
80
92
  return `No file called "${query}" in ${hostLabel(room)}'s archive.${files.length > 0 ? hint(` (It holds: ${files.map(f => f.name).join(', ')}.)`) : ''}`;
81
93
  }
94
+ // HASH (p.257, sprite-power-actions.ts): "only the sprite can
95
+ // unprotect it" while the algorithm holds. Nothing else edits or
96
+ // deletes a hashed file -- including its owner.
97
+ if (file.hashedBy) {
98
+ return `${file.name} is wrapped in something that is not encryption -- a Resonance algorithm, and it does not answer to Computer. ${hint(`(${file.hashedBy} sealed it. Only ${file.hashedBy} unseals it -- or decompile it and the file is corrupted for good.)`)}`;
99
+ }
82
100
  const marks = room.hostMarksBy.get(actor.name) ?? 0;
83
101
  if (marks < 1) {
84
102
  return `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
@@ -99,8 +117,8 @@ export class EditFileCommand extends Command {
99
117
  const defence = hostDefensePool(host);
100
118
  const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
101
119
  const resist = rollPool(defence.pool);
102
- if (this.scene.isHumanControlled(actor)) {
103
- this.logger.meta(`Edit File (delete) -- Computer + Logic [Data Processing]: ${formatRoll(attempt)}`);
120
+ if (showsMechanics(this.scene, actor)) {
121
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Edit File (delete) -- Computer + Logic [Data Processing]: ${formatRoll(attempt)}`);
104
122
  this.logger.meta(` v. ${hostLabel(room)} (${defence.label}, rating standing in for Intuition): ${formatRoll(resist)}`);
105
123
  }
106
124
  actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
@@ -0,0 +1,237 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { rollPool, formatRoll } from '../utilities/dice.js';
4
+ import { billAction } from '../utilities/action-cost.js';
5
+ import { hostDefensePool, overwatchFromDefense } from '../utilities/matrix-intrusion.js';
6
+ import { accrueOverwatch } from '../utilities/overwatch.js';
7
+ import { leaveMatrix } from '../utilities/planes.js';
8
+ import { hostLabel, matchesHostName } from '../utilities/grid-names.js';
9
+ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
10
+ import { MAX_MARKS } from '../utilities/marks.js';
11
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
12
+ export class EraseMarkCommand extends Command {
13
+ static verb = 'unmark';
14
+ static description = 'Erase Mark (Complex Action, SR5 p.239): "unmark me" scrubs the marks others hold on your persona; "unmark <icon>" needs three marks on that icon first. Computer + Logic [Attack] v. Willpower + Firewall. An Attack action -- GOD counts it. Also "erase mark on <icon>".';
15
+ static npcPolicy = 'authorized';
16
+ /** p.239: two marks at -4, three at -10; one is unpenalised. */
17
+ static penaltyFor(count) {
18
+ if (count >= 3)
19
+ return -10;
20
+ if (count === 2)
21
+ return -4;
22
+ return 0;
23
+ }
24
+ /**
25
+ * "unmark me" / "unmark <persona>" / "unmark <host>", plus a leading
26
+ * count: "unmark 2 marks on Deditri". The verb is also reached as
27
+ * "erase mark[s] on <icon>" (commands/edit-file.ts routes it here),
28
+ * so the filler words are stripped rather than parsed.
29
+ */
30
+ parse(args) {
31
+ const words = args
32
+ .map(w => w.toLowerCase())
33
+ .filter(w => !/^(mark|marks|on|from|off|the|my|a)$/.test(w));
34
+ let count = 1;
35
+ if (words.length > 0 && /^[123]$/.test(words[0])) {
36
+ count = Number(words[0]);
37
+ words.shift();
38
+ }
39
+ return { count, query: words.join(' ').trim() };
40
+ }
41
+ resolve(query) {
42
+ const actor = this.actor;
43
+ if (query === '' || /^(me|myself|self|persona)$/.test(query)) {
44
+ return { kind: 'self', player: actor };
45
+ }
46
+ const rooms = Object.values(this.scene.getRooms());
47
+ const host = rooms.find(r => r.hasNode && matchesHostName(query, r));
48
+ if (host)
49
+ return { kind: 'host', room: host };
50
+ const personas = this.scene.allActors.filter(a => a !== actor && a.plane === 'matrix');
51
+ const picked = fuzzyPickName(query, personas.map(p => p.name));
52
+ const who = picked ? personas.find(p => p.name === picked) : undefined;
53
+ if (who)
54
+ return { kind: 'persona', player: who };
55
+ return undefined;
56
+ }
57
+ /** How many marks the ACTOR holds on the target icon -- the p.239 gate. */
58
+ marksHeldOn(target) {
59
+ const actor = this.actor;
60
+ if (target.kind === 'host')
61
+ return target.room.host.marksBy.get(actor.name) ?? 0;
62
+ return target.player.panMarksBy.get(actor.name) ?? 0;
63
+ }
64
+ labelFor(target) {
65
+ if (target.kind === 'host')
66
+ return hostLabel(target.room);
67
+ return target.kind === 'self' ? 'your own persona' : `${target.player.name}'s persona`;
68
+ }
69
+ /**
70
+ * Every icon holding a key on the target, each able to give up ONE of
71
+ * them at a time -- `drop()` decrements, it does not clear the entry.
72
+ * A multi-mark erase calls it once per mark, which is what makes the
73
+ * -4/-10 penalties mean anything.
74
+ *
75
+ * THE MARKS ON YOU ARE NOT ALL ON ONE LEDGER. A host that made you
76
+ * keeps its key in Host.marksOn, not in your PAN ledger -- so "unmark
77
+ * me" has to sweep both, or it would leave the ice's mark exactly where
78
+ * the player can still see it on their own sheet (utilities/marks.ts
79
+ * marksOnYou reads the same two places).
80
+ */
81
+ placersOn(target) {
82
+ const actor = this.actor;
83
+ const out = [];
84
+ const dec = (m, key) => {
85
+ const n = (m.get(key) ?? 0) - 1;
86
+ if (n > 0)
87
+ m.set(key, n);
88
+ else
89
+ m.delete(key);
90
+ };
91
+ if (target.kind === 'self') {
92
+ for (const [placer, count] of actor.panMarksBy.entries()) {
93
+ if (count > 0)
94
+ out.push({ name: placer, count, drop: () => dec(actor.panMarksBy, placer) });
95
+ }
96
+ for (const room of Object.values(this.scene.getRooms())) {
97
+ const host = room.host;
98
+ const n = host?.marksOn.get(actor.name) ?? 0;
99
+ if (n > 0 && host) {
100
+ out.push({
101
+ name: hostLabel(room),
102
+ count: n,
103
+ drop: () => {
104
+ dec(host.marksOn, actor.name);
105
+ // A key gone is a sighting gone: an icon it no longer
106
+ // recognises is one it has to find again (Host.spotted).
107
+ if (!host.marksOn.has(actor.name))
108
+ host.spotted.delete(actor.name);
109
+ },
110
+ });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ const ledger = target.kind === 'host' ? target.room.host.marksBy : target.player.panMarksBy;
116
+ for (const [placer, count] of ledger.entries()) {
117
+ // Your own key is not a mark you "erase" -- p.239 is about reaching
118
+ // into an icon to scrub somebody ELSE's recognition pattern.
119
+ if (count > 0 && placer !== actor.name) {
120
+ out.push({ name: placer, count, drop: () => dec(ledger, placer) });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ async execute(args = []) {
126
+ const actor = this.actor;
127
+ if (actor.plane !== 'matrix') {
128
+ return `Marks are Matrix keys -- you scrub them with a persona. ${hint(`"jack in" first.`)}`;
129
+ }
130
+ const { count, query } = this.parse(args);
131
+ const target = this.resolve(query);
132
+ if (!target) {
133
+ return `No icon here answers to "${query}". ${hint(`("unmark me" for the marks others hold on YOU; "unmark <host or persona>" needs three of your marks on it first.)`)}`;
134
+ }
135
+ const label = this.labelFor(target);
136
+ const held = this.marksHeldOn(target);
137
+ const placers = this.placersOn(target);
138
+ if (placers.length === 0) {
139
+ return target.kind === 'self'
140
+ ? `Nothing is holding a key on you -- your icon is clean.`
141
+ : `Nobody else holds a mark on ${label} for you to scrub.`;
142
+ }
143
+ // THE THREE-MARK GATE (p.239), waived on your own icon -- see the
144
+ // owner-exemption ruling in the class comment.
145
+ if (target.kind !== 'self' && held < MAX_MARKS) {
146
+ return [
147
+ `You hold ${held}/${MAX_MARKS} marks on ${label} -- Erase Mark needs THREE on the icon you are scrubbing (p.239), not on whoever placed the mark.`,
148
+ hint(` ("hack" ${label} up to ${MAX_MARKS}. To clear marks off YOURSELF instead, "unmark me" -- or "reboot"/"jack out", which wipe every mark on you for free, p.242.)`),
149
+ ].join('');
150
+ }
151
+ const wanted = Math.min(count, MAX_MARKS, placers.reduce((n, p) => n + p.count, 0));
152
+ // p.239: "If you go for more than one mark, all of the marks must be
153
+ // on the same icon and from the same icon" -- so a multi-mark erase
154
+ // spends its penalty against ONE placer's keys, not a spread.
155
+ const chosen = placers.reduce((best, p) => (p.count > best.count ? p : best), placers[0]);
156
+ const take = Math.min(wanted, chosen.count);
157
+ const penalty = EraseMarkCommand.penaltyFor(take);
158
+ const burned = actor.burnedAttributeRefusal('attack');
159
+ if (burned)
160
+ return burned;
161
+ const bill = billAction(this.scene, actor, 'complex', 'Erase Mark', { attack: true });
162
+ if (bill)
163
+ return bill;
164
+ const computer = actor.skillRating('computer');
165
+ const pool = Math.max(1, actor.logic + (computer > 0 ? computer : -1)
166
+ + penalty + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
167
+ const limit = actor.matrixAttribute('attack');
168
+ const defence = this.defenceFor(target);
169
+ const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
170
+ const resist = rollPool(defence.pool);
171
+ if (showsMechanics(this.scene, actor)) {
172
+ const many = take > 1 ? ` for ${take} marks (${penalty})` : '';
173
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Erase Mark${many} -- Computer + Logic [Attack]: ${formatRoll(attempt)}`);
174
+ this.logger.meta(` ${chosen.name} resists (${defence.label}): ${formatRoll(resist)}`);
175
+ }
176
+ actor.performAction('erases a mark', `${chosen.name}'s key on ${label}`, { quiet: this.scene.isHumanControlled(actor) });
177
+ // AN ATTACK ACTION IS ILLEGAL, and GOD bills the defender's hits
178
+ // (p.231-232) whether or not it landed.
179
+ const god = this.godBill(overwatchFromDefense(resist.hits), 'Erase Mark (attack)');
180
+ if (attempt.hits <= resist.hits) {
181
+ this.logger.write(`${actor.name} failed Erase Mark on ${label} (${chosen.name}): ${attempt.hits} v ${resist.hits}.`);
182
+ return [
183
+ `The key holds. ${chosen.name}'s mark is still written into ${label} and your scrub slides off it.`,
184
+ hint(` (A Complex Action each try${take > 1 ? `; going for ${take} at once cost you ${penalty} dice` : ''}.)`),
185
+ ...(god.length > 0 ? [`\n${god.join('\n')}`] : []),
186
+ ].join('');
187
+ }
188
+ for (let i = 0; i < take; i++)
189
+ chosen.drop();
190
+ actor.noteConditionChanged();
191
+ this.scene.updateStatus();
192
+ this.logger.write(`${actor.name} erased ${take} of ${chosen.name}'s mark(s) on ${label}: ${attempt.hits} v ${resist.hits}.`);
193
+ const left = this.remainingOn(target, actor);
194
+ return [
195
+ `{light-blue-fg}${chosen.name}'s ${take > 1 ? `${take} keys slough` : 'key sloughs'} off ${label} -- the recognition pattern unwrites itself and ${target.kind === 'self' ? 'your icon stops answering to it' : 'the icon forgets it ever agreed'}.{/light-blue-fg}`,
196
+ target.kind === 'self'
197
+ ? (left > 0
198
+ ? `\n${left} mark${left === 1 ? '' : 's'} still on you.`
199
+ : `\nNothing is holding a key on you now.`)
200
+ : '',
201
+ ...(god.length > 0 ? [`\n${god.join('\n')}`] : []),
202
+ ].join('');
203
+ }
204
+ /** p.239: Willpower + Firewall. A host has no Willpower; its rating stands in. */
205
+ defenceFor(target) {
206
+ if (target.kind === 'host') {
207
+ const d = hostDefensePool(target.room.host);
208
+ return { label: `${d.label}, rating standing in for Willpower`, pool: d.pool };
209
+ }
210
+ const who = target.player;
211
+ return {
212
+ label: 'Willpower + Firewall',
213
+ pool: Math.max(0, who.willpower + who.matrixAttribute('firewall')),
214
+ };
215
+ }
216
+ remainingOn(target, actor) {
217
+ if (target.kind !== 'self')
218
+ return 0;
219
+ let n = 0;
220
+ for (const v of actor.panMarksBy.values())
221
+ n += v;
222
+ for (const room of Object.values(this.scene.getRooms())) {
223
+ n += room.host?.marksOn.get(actor.name) ?? 0;
224
+ }
225
+ return n;
226
+ }
227
+ /** GOD's bill, and the forced dump when it converges -- the shape every
228
+ * Attack-action verb in this engine shares (commands/disable.ts). */
229
+ godBill(defenseHits, why) {
230
+ const bill = accrueOverwatch(this.scene, this.actor, defenseHits, why);
231
+ if (bill.converged && this.actor.plane === 'matrix') {
232
+ bill.lines.push(...leaveMatrix(this.scene, this.actor, { forced: true, reason: `GOD force-reboots your persona --` }));
233
+ }
234
+ return bill.lines;
235
+ }
236
+ }
237
+ //# sourceMappingURL=erase-mark.js.map
@@ -13,6 +13,7 @@ import { TapCommand } from './tap.js';
13
13
  import { sameHostSide } from '../models/player.js';
14
14
  import { MAX_MARKS } from '../utilities/marks.js';
15
15
  import { billAction } from '../utilities/action-cost.js';
16
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
16
17
  import { declarationPenalty, parseMarkDeclaration, hostDefensePool, wanDefensePool, freeMatrixPerceptionHits, bruteForceMatrixDv, overwatchFromDefense, GO_BIG_QUALITY, deviceDefensePool } from '../utilities/matrix-intrusion.js';
17
18
  /**
18
19
  * MARKS A PAN INTRUSION MUST ALREADY HOLD before it can command the
@@ -254,8 +255,8 @@ export class HackCommand extends BypassCommand {
254
255
  const bite = rollPool(this.actor.getSoakPool());
255
256
  const dealt = Math.max(0, 3 - bite.hits);
256
257
  // The roll is ticker material; the bite is world (readability pass).
257
- if (this.scene.isHumanControlled(this.actor)) {
258
- this.logger.meta(`Firewall: ${formatRoll(bite)}`);
258
+ if (showsMechanics(this.scene, this.actor)) {
259
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Firewall: ${formatRoll(bite)}`);
259
260
  }
260
261
  const lines = [`The host's defenses snap shut around your intrusion --`];
261
262
  if (dealt > 0) {
@@ -599,9 +600,14 @@ export class HackCommand extends BypassCommand {
599
600
  const defense = viaWan ? wanDefensePool(device, host.host) : deviceDefensePool(device);
600
601
  const defenseRoll = rollPool(defense.pool);
601
602
  const netHits = roll.hits - defenseRoll.hits;
602
- if (this.scene.isHumanControlled(actor)) {
603
+ // THE AUDIENCE, NOT THE ROLLER (wjFBY3zgyWzJP9adw). This asked
604
+ // whether the actor was human, so an ordered sprite's marks landed
605
+ // with no dice anywhere -- the exact report. A companion's master
606
+ // typed the order and is owed its arithmetic.
607
+ if (showsMechanics(this.scene, actor)) {
603
608
  const declaring = declared > 1 ? ` for ${declared} marks (${declarePenalty})` : '';
604
- this.logger.meta(`${mode === 'attack' ? 'Brute Force (attack)' : 'Hack on the Fly (sleaze)'} on ${device.name}${declaring}: ${formatRoll(roll)}`);
609
+ const who = mechanicsActorPrefix(this.scene, actor);
610
+ this.logger.meta(`${who}${mode === 'attack' ? 'Brute Force (attack)' : 'Hack on the Fly (sleaze)'} on ${device.name}${declaring}: ${formatRoll(roll)}`);
605
611
  this.logger.meta(`${defense.label} (DR ${device.rating}): ${formatRoll(defenseRoll)} -- net ${netHits >= 0 ? '+' : ''}${netHits}`);
606
612
  }
607
613
  const godLines = this.godBill(overwatchFromDefense(defenseRoll.hits), `device intrusion (${mode})`);
@@ -738,9 +744,10 @@ export class HackCommand extends BypassCommand {
738
744
  const defense = hostDefensePool(room.host);
739
745
  const defenseRoll = rollPool(defense.pool);
740
746
  const netHits = roll.hits - defenseRoll.hits;
741
- if (this.scene.isHumanControlled(this.actor)) {
747
+ if (showsMechanics(this.scene, this.actor)) {
742
748
  const declaring = declared > 1 ? ` for ${declared} marks (${declarePenalty})` : '';
743
- this.logger.meta(`${mode === 'attack' ? 'Brute Force (attack)' : 'Hack on the Fly (sleaze)'}${declaring}${gridNote(grid)}: ${formatRoll(roll)}`);
749
+ const who = mechanicsActorPrefix(this.scene, this.actor);
750
+ this.logger.meta(`${who}${mode === 'attack' ? 'Brute Force (attack)' : 'Hack on the Fly (sleaze)'}${declaring}${gridNote(grid)}: ${formatRoll(roll)}`);
744
751
  this.logger.meta(`${defense.label}: ${formatRoll(defenseRoll)} -- net ${netHits >= 0 ? '+' : ''}${netHits}`);
745
752
  }
746
753
  return { success: netHits > 0, netHits, defenseHits: defenseRoll.hits };
@@ -853,9 +860,11 @@ export class HackCommand extends BypassCommand {
853
860
  actor.performAction('runs a PAN intrusion', target.name, { quiet: this.scene.isHumanControlled(actor) });
854
861
  this.logger.write(`${actor.name} PAN-hacks ${target.name}: ${attack.hits} vs ${defense.hits} (silent=${target.runningSilent}).`);
855
862
  // Dice anatomy to the Mechanics ticker (readability pass) --
856
- // player-gated like every roll site: an NPC's probe isn't your dice.
857
- if (this.scene.isHumanControlled(actor)) {
858
- this.logger.meta(`PAN intrusion (${mode === 'attack' ? 'brute force' : 'sleaze'}${markDice > 0 ? `, +${markDice} marks` : ''})${noiseNote(noise)}${gridNote(grid)}: ${formatRoll(attack)}`);
863
+ // audience-gated: a passer-by's probe isn't your dice, but the agent
864
+ // YOU set on a PAN is (showsMechanics).
865
+ if (showsMechanics(this.scene, actor)) {
866
+ const who = mechanicsActorPrefix(this.scene, actor);
867
+ this.logger.meta(`${who}PAN intrusion (${mode === 'attack' ? 'brute force' : 'sleaze'}${markDice > 0 ? `, +${markDice} marks` : ''})${noiseNote(noise)}${gridNote(grid)}: ${formatRoll(attack)}`);
859
868
  this.logger.meta(` their firewall: ${formatRoll(defense)}`);
860
869
  }
861
870
  // GOD counts the defender's hits as evidence (canon p.231-232) --
@@ -1023,8 +1032,8 @@ export class HackCommand extends BypassCommand {
1023
1032
  // seed's enforcer still fumbles terminals about like it used to.
1024
1033
  const pool = Math.max(1, this.actor.logic + this.actor.skillRating('hacking') + this.actor.bonus('hacking') + this.actor.woundModifier - this.actor.sustainingPenalty);
1025
1034
  const roll = rollPool(pool, undefined, { gremlins: this.actor.deckGremlins });
1026
- if (this.scene.isHumanControlled(this.actor)) {
1027
- this.logger.meta(`Hacking: ${formatRoll(roll)} (2+ to crack)`);
1035
+ if (showsMechanics(this.scene, this.actor)) {
1036
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Hacking: ${formatRoll(roll)} (2+ to crack)`);
1028
1037
  }
1029
1038
  return roll.hits >= 2;
1030
1039
  }
@@ -7,6 +7,7 @@ import { billAction, encounterOf } from '../utilities/action-cost.js';
7
7
  import { isSpotted } from '../utilities/ic.js';
8
8
  import { MAX_MARKS } from '../utilities/marks.js';
9
9
  import { hostLabel } from '../utilities/grid-names.js';
10
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
10
11
  /**
11
12
  * HIDE (SR5 p.240, RAG-checked 2026-09-14): a Complex Action, no marks
12
13
  * required, Electronic Warfare + Intuition [Sleaze] v. Intuition + Data
@@ -67,8 +68,8 @@ export class HideCommand extends Command {
67
68
  const limit = actor.matrixAttribute('sleaze');
68
69
  const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
69
70
  const defence = rollPool(room.hostRating + room.hostAttributes().dataProcessing);
70
- if (this.scene.isHumanControlled(actor)) {
71
- this.logger.meta(`Hide -- Electronic Warfare + Intuition [Sleaze]: ${formatRoll(attempt)}`);
71
+ if (showsMechanics(this.scene, actor)) {
72
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Hide -- Electronic Warfare + Intuition [Sleaze]: ${formatRoll(attempt)}`);
72
73
  this.logger.meta(` v. ${hostLabel(room)} (Host Rating + Data Processing, rating standing in for Intuition): ${formatRoll(defence)}`);
73
74
  }
74
75
  actor.performAction('tries to hide', `persona folding into ${hostLabel(room)}'s traffic`, { quiet: this.scene.isHumanControlled(actor) });
@@ -4,6 +4,7 @@ import { enterMatrix, leaveMatrix } from '../utilities/planes.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { rollPool, formatRoll } from '../utilities/dice.js';
6
6
  import { billAction } from '../utilities/action-cost.js';
7
+ import { showsMechanics } from '../utilities/mechanics-audience.js';
7
8
  /**
8
9
  * The Matrix door, both directions: "jack in [hot|cold]" and "jack out".
9
10
  * Needs a working CYBERDECK carried (a commlink is calls and AR only) --
@@ -65,7 +66,7 @@ export class JackCommand extends Command {
65
66
  const lines = [
66
67
  `You throw everything at the disconnect --`,
67
68
  ];
68
- if (this.scene.isHumanControlled(actor)) {
69
+ if (showsMechanics(this.scene, actor)) {
69
70
  this.logger.meta(firewall > 0
70
71
  ? `Jack Out -- Hardware + Willpower [Firewall]: ${formatRoll(attempt)}`
71
72
  : `Jack Out -- Hardware + Willpower, UNBRACKETED (no functional Firewall left, p.229): ${formatRoll(attempt)}`);
@@ -73,7 +74,7 @@ export class JackCommand extends Command {
73
74
  const held = [];
74
75
  for (const name of lockers) {
75
76
  const hold = rollPool(room.hostRating * 2, room.hostAttributes().attack);
76
- if (this.scene.isHumanControlled(actor)) {
77
+ if (showsMechanics(this.scene, actor)) {
77
78
  this.logger.meta(` v. ${name} keep-alive (Host Rating x2 [Attack]): ${formatRoll(hold)}`);
78
79
  }
79
80
  // Ties go to the locker: you need to BEAT each of them.
@@ -18,6 +18,7 @@ import { spotsActive, whoIsWhereLine, highlightSpots, highlightActors, canReach,
18
18
  import { rollPool, formatRoll } from '../utilities/dice.js';
19
19
  import { findCatalogItem, describeEffect } from '../utilities/catalog.js';
20
20
  import { billAction } from '../utilities/action-cost.js';
21
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
21
22
  export class LookCommand extends Command {
22
23
  static verb = 'look';
23
24
  static description = 'Examine your surroundings';
@@ -166,8 +167,8 @@ export class LookCommand extends Command {
166
167
  // the two verbs cannot drift apart again.
167
168
  const { pool, limit } = perceptionTest(this.actor);
168
169
  const roll = rollPool(pool, limit);
169
- if (this.scene.isHumanControlled(this.actor)) {
170
- this.logger.meta(`Perception (look): ${formatRoll(roll)} (2+ spots partial cover, 4+ good)`);
170
+ if (showsMechanics(this.scene, this.actor)) {
171
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Perception (look): ${formatRoll(roll)} (2+ spots partial cover, 4+ good)`);
171
172
  }
172
173
  const who = whoIsWhereLine(room, this.actor, roll.hits);
173
174
  if (who)
@@ -182,16 +182,26 @@ export class OrderCommand extends Command {
182
182
  // task, then the fight's own per-fight charge found the bargain
183
183
  // already at zero and dismissed the spirit mid-lunge.
184
184
  const isAttackOrder = /\b(attack|kill|subdue|fight|slay|destroy)\b/i.test(cmd);
185
- const billsService = entry.kind === 'spirit' && !isAttackOrder;
186
- if (billsService && (entry.services ?? 0) <= 0) {
187
- return `${entry.npc.name} regards you flatly. The bargain is spent -- no services remain.`;
185
+ // A SPRITE'S TASKS ARE A BARGAIN TOO (wjFBY3zgyWzJP9adw). This billed
186
+ // spirits only, so an ordered sprite worked for free and its ledger
187
+ // line never printed -- while combat-exchange.ts has always charged
188
+ // BOTH kinds per fight. Canon is explicit: a compiled sprite owes
189
+ // (hits) tasks and "using a power costs one task" (p.254, p.256).
190
+ // The vocabulary differs -- a spirit owes services, a sprite tasks --
191
+ // so the line says which.
192
+ const billsTask = (entry.kind === 'spirit' || entry.kind === 'sprite') && !isAttackOrder;
193
+ const owed = entry.kind === 'sprite' ? 'task' : 'service';
194
+ if (billsTask && (entry.services ?? 0) <= 0) {
195
+ return entry.kind === 'sprite'
196
+ ? `${entry.npc.name} flickers and does not move. Its tasks are spent -- compile or register another.`
197
+ : `${entry.npc.name} regards you flatly. The bargain is spent -- no services remain.`;
188
198
  }
189
199
  const chargeService = () => {
190
- if (!billsService)
200
+ if (!billsTask)
191
201
  return;
192
202
  entry.services = (entry.services ?? 0) - 1;
193
203
  // Resource arithmetic -- Mechanics ticker (readability pass).
194
- this.logger.meta(`👥 One service spent (${entry.services} left).`, 'green');
204
+ this.logger.meta(`👥 ${entry.npc.name}: one ${owed} spent (${entry.services} left).`, 'green');
195
205
  };
196
206
  // Tier 1 -- ATOMIC: a known command runs deterministically, no AI.
197
207
  // A parsed command whose RESULT is a refusal is NOT obedience: a real
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { rollPool, formatRoll } from '../utilities/dice.js';
3
3
  import { accrueOverwatch, settledScore, CONVERGENCE_AT } from '../utilities/overwatch.js';
4
4
  import { leaveMatrix } from '../utilities/planes.js';
5
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
5
6
  /**
6
7
  * Check Overwatch Score (canon p.238): Electronic Warfare + Logic
7
8
  * [Sleaze] vs GOD's flat 6 dice. Win the read and you see the exact
@@ -40,8 +41,8 @@ export class OverwatchCommand extends Command {
40
41
  // (canon p.235-236, Player.matrixActionPenalty).
41
42
  const mine = rollPool(Math.max(1, actor.logic + (skill > 0 ? skill : -1) + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty), limit);
42
43
  const god = rollPool(6);
43
- if (this.scene.isHumanControlled(actor)) {
44
- this.logger.meta(`Check Overwatch (EW, sleaze): ${formatRoll(mine)}`);
44
+ if (showsMechanics(this.scene, actor)) {
45
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Check Overwatch (EW, sleaze): ${formatRoll(mine)}`);
45
46
  this.logger.meta(` GOD's counter-sweep: ${formatRoll(god)}`);
46
47
  }
47
48
  // The number they glimpse is the score BEFORE this check's own bill