@maka/maka-cli 5.390.0 → 5.392.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 (68) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +1 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +60 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +5 -2
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +8 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +4 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +16 -150
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/crack.js +76 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +42 -6
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/disarm.js +64 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +23 -46
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +23 -6
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +15 -3
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +55 -17
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +15 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/leap.js +23 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +40 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +16 -15
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +13 -3
  21. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/factories/authored-run.js +632 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/factories/catalog-item-seed.js +78 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +7 -44
  25. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +8 -10
  26. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +12 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +30 -52
  28. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +112 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +123 -3
  30. package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +52 -4
  31. package/bundle/typescript/src/commands/game/sideQuest/game.js +17 -4
  32. package/bundle/typescript/src/commands/game/sideQuest/models/camera.js +8 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +14 -21
  34. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +14 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +62 -9
  37. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +11 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +141 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +3 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +18 -11
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js +3 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +4 -2
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +7 -2
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +9 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/utilities/district-plane.js +109 -27
  47. package/bundle/typescript/src/commands/game/sideQuest/utilities/door-watch.js +70 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/utilities/falling.js +27 -0
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/file-guard.js +120 -0
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +10 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +12 -3
  52. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +23 -5
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +29 -2
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/ledge-jump.js +86 -0
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +2 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +29 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/owner-guard.js +77 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/path-run.js +4 -2
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +3 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +66 -3
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +8 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/spider.js +50 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +20 -6
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +193 -8
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +2 -2
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +116 -0
  68. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+ import { Command } from './command.js';
2
+ import { gridActionModifier } from '../utilities/grids.js';
3
+ import { hint } from '../utilities/hints.js';
4
+ import { rollPool, formatRoll } from '../utilities/dice.js';
5
+ import { billAction } from '../utilities/action-cost.js';
6
+ import { hostLabel } from '../utilities/grid-names.js';
7
+ import { detonate, pickHostFile } from '../utilities/file-guard.js';
8
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
9
+ /**
10
+ * DISARM DATA BOMB (SR5 p.239, RAG-checked 2026-10-06): Complex Action,
11
+ * Marks Required none, Software + Intuition [Firewall] v. Data Bomb
12
+ * Rating x 2. "If you get any net hits, the Data Bomb is removed and
13
+ * deleted. If you fail, the Data Bomb activates." A Firewall action
14
+ * (p.244) -- legal, so GOD does not count it.
15
+ *
16
+ * You disarm what you have FOUND: a bomb is detected with Matrix
17
+ * Perception (p.242), "look <file>" inside the host. Software cannot be
18
+ * defaulted (p.145, "Default: No").
19
+ */
20
+ export class DisarmCommand extends Command {
21
+ static verb = 'disarm';
22
+ static description = 'Disarm Data Bomb (SR5 p.239, Complex Action): "disarm <file>" -- Software + Intuition [Firewall] v. the bomb\'s Rating x 2, once Matrix Perception ("look <file>" inside the host) has turned the bomb up. Any net hit deletes the bomb; failure sets it off. Legal.';
23
+ async execute(args = []) {
24
+ const actor = this.actor;
25
+ const found = pickHostFile(actor, args.join(' ').trim(), 'disarm');
26
+ if (typeof found === 'string')
27
+ return found;
28
+ const { host, file } = found;
29
+ if (!file.dataBomb || !file.bombSeenBy.has(actor.name)) {
30
+ return `You see no data bomb on ${file.name} to disarm. ${hint(`(Matrix Perception finds one: "look ${file.name}".)`)}`;
31
+ }
32
+ const software = actor.skillRating('software');
33
+ if (software <= 0) {
34
+ return `Disarming a data bomb is Software, and Software cannot be defaulted (p.145) -- without the skill there is no test to try.`;
35
+ }
36
+ const burned = actor.burnedAttributeRefusal('firewall');
37
+ if (burned)
38
+ return burned;
39
+ const bill = billAction(this.scene, actor, 'complex', 'Disarm Data Bomb');
40
+ if (bill)
41
+ return bill;
42
+ const rating = file.dataBomb.rating;
43
+ const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, actor.currentGrid);
44
+ const pool = Math.max(1, actor.intuition + software + actor.bonus('software')
45
+ + actor.matrixActionPenalty + grid.total + actor.woundModifier - actor.sustainingPenalty);
46
+ const attempt = rollPool(pool, actor.matrixAttribute('firewall'), { gremlins: actor.deckGremlins });
47
+ const resist = rollPool(rating * 2);
48
+ if (showsMechanics(this.scene, actor)) {
49
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Disarm Data Bomb -- Software + Intuition [Firewall]: ${formatRoll(attempt)}`);
50
+ this.logger.meta(` v. Data Bomb ${rating} x 2: ${formatRoll(resist)}`);
51
+ }
52
+ actor.performAction('works at a data bomb', `on ${file.name} in ${hostLabel(host.over)}`, { quiet: this.scene.isHumanControlled(actor) });
53
+ if (attempt.hits <= resist.hits) {
54
+ this.logger.write(`${actor.name} failed to disarm the bomb on "${file.name}": ${attempt.hits} v ${resist.hits}.`);
55
+ return [`Your probe trips the trigger.`, ...detonate(this.scene, this.logger, actor, host, file).lines].join('\n');
56
+ }
57
+ file.dataBomb = undefined;
58
+ file.bombSeenBy.clear();
59
+ this.logger.write(`${actor.name} disarmed the bomb on "${file.name}": ${attempt.hits} v ${resist.hits}.`);
60
+ this.scene.updateStatus();
61
+ return `{light-blue-fg}You unpick the payload thread by thread -- the data bomb on ${file.name} deletes itself, harmless.{/light-blue-fg}`;
62
+ }
63
+ }
64
+ //# sourceMappingURL=disarm.js.map
@@ -1,19 +1,19 @@
1
1
  import { CastCommand } from './cast.js';
2
- import { BypassCommand } from './bypass-command.js';
3
2
  import { rollPool, formatRoll } from '../utilities/dice.js';
4
3
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
4
  import { billAction } from '../utilities/action-cost.js';
6
5
  import { roomAtCell } from '../utilities/district-plane.js';
7
6
  /**
8
- * The magical barrier's OWN verb (player ruling: "solve" was too broad --
9
- * a ward wants "dispel", not a generic answer verb), and the second half
10
- * of counterspelling (RAW p.295): "dispel <name>" unweaves a SUSTAINED
11
- * SPELL off an actor in the room -- an enemy mage's invisibility, an
12
- * armor weave, quickened reflexes -- Magic + counterspelling opposed by
13
- * their grip on it. Barrier flow is unchanged: no actor match falls
14
- * through to the ward route (same pool, backlash on failure, both
15
- * inherited from CastCommand). Bare "cast" keeps working as it always
16
- * has.
7
+ * COUNTERSPELLING'S SECOND HALF (RAW p.295): "dispel <name>" unweaves a
8
+ * SUSTAINED SPELL off an actor in the room -- an enemy mage's
9
+ * invisibility, an armor weave, quickened reflexes, a barrier spell --
10
+ * Magic + counterspelling opposed by their grip on it.
11
+ *
12
+ * A WARD IS NOT DISPELLED (user 2026-10-06). It used to be: a ward was a
13
+ * door device this verb unweaved, with backlash. A ward is a ritual's
14
+ * astral barrier over a room (utilities/wards.ts), and canon gives no
15
+ * rule for dispelling one -- it is pressed through or attacked, from the
16
+ * astral (p.316).
17
17
  *
18
18
  * Registered under both spellings ("dispel" is the book's, "dispell" is
19
19
  * how half the table types it).
@@ -30,8 +30,7 @@ const SUSTAINED_EFFECTS = [
30
30
  // The barrier pair (ruling 2026-08-24; canon p.294). MANA first:
31
31
  // find() takes the first word match, so "dispel mana barrier" lands
32
32
  // here instead of on the physical entry's 'barrier'. With no
33
- // spell-wall standing, these words fall through to the warded-door
34
- // route as before (holderless named effects return undefined below).
33
+ // spell-wall standing, these words find no holder (undefined below).
35
34
  { key: 'manabarrier', words: ['manabarrier', 'mana'], label: 'mana barrier',
36
35
  has: p => !!p.sustainedManaBarrier, clear: p => { p.sustainedManaBarrier = undefined; } },
37
36
  { key: 'barrier', words: ['barrier', 'wall'], label: 'force barrier',
@@ -39,23 +38,7 @@ const SUSTAINED_EFFECTS = [
39
38
  ];
40
39
  export class DispelCommand extends CastCommand {
41
40
  static verb = 'dispel';
42
- static description = 'Unweave magic -- Magic + counterspelling. A barrier: "dispel" (or "dispel north"/"dispel <name>"; fail and the backlash BITES, the barrier seals for good). A SUSTAINED SPELL on someone: "dispel <name>" or "dispel invisibility on <name>" -- opposed by their grip; no backlash, retry freely.';
43
- verbName = 'dispel';
44
- noDeviceMessage = `There's no magical barrier here to dispel.`;
45
- /**
46
- * RAW dispelling (p.295): Magic + counterspelling -- the unweave is a
47
- * parry, not a working, so the CASTING pool (cast's ward route) stays
48
- * cast's. Unskilled follows the house -1 default; a gear-only caster
49
- * (grimoire, no Magic) is down to scraps here -- their route is still
50
- * "cast", which rolls the full casting pool.
51
- */
52
- async determineOutcome() {
53
- const roll = rollPool(this.dispelPool());
54
- if (this.scene.isHumanControlled(this.actor)) {
55
- this.logger.meta(`Counterspelling: ${formatRoll(roll)} (2+ to unweave)`);
56
- }
57
- return roll.hits >= 2;
58
- }
41
+ static description = 'Unweave a SUSTAINED SPELL -- Magic + counterspelling, opposed by its caster\'s grip: "dispel <name>", "dispel invisibility on <name>", "dispel barrier". No backlash; retry freely. A ward is not dispelled -- press through it or attack it from the astral.';
59
42
  dispelPool() {
60
43
  const skill = this.actor.skillRating('counterspelling');
61
44
  return Math.max(1, this.actor.magic + (skill > 0 ? skill : -1) + this.actor.woundModifier - this.actor.sustainingPenalty);
@@ -70,27 +53,27 @@ export class DispelCommand extends CastCommand {
70
53
  return `Unweaving is Sorcery, and your gift is aspected to conjuring -- you can see the weave, not pull it.`;
71
54
  }
72
55
  if (!this.actor.isCaster()) {
73
- return `Unweaving a ward takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
56
+ return `Unweaving a spell takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
57
+ }
58
+ if (!args || args.length === 0) {
59
+ return `Dispel what? "dispel <name>" strips a sustained spell off someone here.`;
74
60
  }
75
61
  // DISPELLING IS A COMPLEX ACTION (SR5 p.295) inside a Combat Turn.
76
62
  const bill = billAction(this.scene, this.actor, 'complex', 'Dispel');
77
63
  if (bill)
78
64
  return bill;
79
- // An actor's name beats a barrier noun -- "dispel ghost" means the
80
- // mage, "dispel ward" falls through to the barrier route below.
81
- if (args && args.length > 0) {
82
- const sustained = this.tryDispelSustained(args);
83
- if (sustained !== undefined)
84
- return sustained;
65
+ const sustained = this.tryDispelSustained(args);
66
+ if (sustained !== undefined)
67
+ return sustained;
68
+ if (args.some(w => /^wards?$/i.test(w))) {
69
+ return `A ward is a ritual's astral barrier, not a sustained spell -- there is no grip on it to unweave. From the astral, "go" presses through it and "attack ward" tears at it (p.316).`;
85
70
  }
86
- // Skip CastCommand's spell-name resolution entirely -- dispel only
87
- // ever means a barrier or a sustained spell.
88
- return BypassCommand.prototype.execute.call(this, args);
71
+ return `No sustained spell by that name here to unweave.`;
89
72
  }
90
73
  /**
91
74
  * "dispel <name>" / "dispel invisibility on <name>": strip a sustained
92
75
  * spell off an actor here. Returns undefined when the words name no
93
- * actor in the room (the barrier flow's turn), a message otherwise.
76
+ * actor in the room, a message otherwise.
94
77
  */
95
78
  tryDispelSustained(args) {
96
79
  const lowered = args.map(w => w.toLowerCase());
@@ -143,12 +126,6 @@ export class DispelCommand extends CastCommand {
143
126
  if ((target.sustainedForms?.length ?? 0) > 0) {
144
127
  return `What's woven around ${target.name} isn't mana -- it's RESONANCE. Your counterspelling finds nothing to grip.`;
145
128
  }
146
- // A fuzzy actor hit holding nothing, with a warded barrier standing
147
- // here and no effect named ("dispel ward" grazing an NPC called
148
- // Warden): the word was probably aimed at the barrier -- yield.
149
- const barriers = (room.openableDevices() ?? []).filter(d => d.kind === 'ward');
150
- if (barriers.length > 0 && !named)
151
- return undefined;
152
129
  return target === this.actor
153
130
  ? `Nothing of yours is sustained right now.`
154
131
  : `No sustained spell clings to ${target.name}.`;
@@ -9,6 +9,7 @@ import { hostLabel } from '../utilities/grid-names.js';
9
9
  import { editFileBlocked, rollEditFile } from '../utilities/edit-file-test.js';
10
10
  import { copyFile, deviceHas, fileOnto, findDataDevice, libraryDevice } from '../utilities/data-library.js';
11
11
  import { NPC } from '../models/npc.js';
12
+ import { guardFile, splitPasscode } from '../utilities/file-guard.js';
12
13
  /**
13
14
  * "download" -- COPY A FILE OFF THE GRID INTO YOUR DEVICE'S LIBRARY.
14
15
  *
@@ -50,7 +51,9 @@ export class DownloadCommand extends Command {
50
51
  static description = 'Copy a file off the Matrix into your device\'s library -- "download <file>" (or "download <file> to <device>"). Edit File (SR5 p.239, a Complex Action): Computer + Logic [Data Processing] v. the host, with a mark on it -- no roll once the host is cracked, or for data loose on the grid or held by your own sprites. The host keeps its original. "inv" lists your libraries; "transfer" moves files between devices or onto a chip.';
51
52
  async execute(args = []) {
52
53
  const actor = this.actor;
53
- let words = args.join(' ').trim();
54
+ // "download <file> with <passcode>" walks past its data bomb (p.242).
55
+ const { rest: named, passcode } = splitPasscode(args);
56
+ let words = named.join(' ').trim();
54
57
  // "... to <device>": split on the LAST " to ", and only when the tail
55
58
  // names a data device you carry -- a file called "Letter to Krow"
56
59
  // still downloads whole.
@@ -106,7 +109,7 @@ export class DownloadCommand extends Command {
106
109
  return elsewhere;
107
110
  return `No file called "${words}" within reach.${hint(` ("download" on its own lists what is.)`)}`;
108
111
  }
109
- const out = this.pull(source, target);
112
+ const out = this.pull(source, target, passcode);
110
113
  return out.text;
111
114
  }
112
115
  /**
@@ -186,7 +189,7 @@ export class DownloadCommand extends Command {
186
189
  * only when a file actually landed -- take-all stops on a refusal that
187
190
  * is about the ACTOR (out of actions), not the file.
188
191
  */
189
- pull(source, target) {
192
+ pull(source, target, passcode) {
190
193
  const actor = this.actor;
191
194
  const item = source.item;
192
195
  // A LOOSE END IS ERASED, NOT DOWNLOADED: the desk keeps its copy
@@ -245,12 +248,26 @@ export class DownloadCommand extends Command {
245
248
  const bill = billAction(this.scene, actor, 'complex', 'Edit File (copy)');
246
249
  if (bill)
247
250
  return { ok: false, text: bill, stop: true };
251
+ // THE FILE'S OWN DEFENCES (utilities/file-guard.ts, p.239: a
252
+ // protected file's copy "automatically fails", and a bomb "goes off
253
+ // on you"). A cracked HOST is not a cracked FILE -- this gate stands
254
+ // whether or not the host roll does.
255
+ let blast = '';
256
+ if (host) {
257
+ const guard = guardFile(this.scene, this.logger, actor, host, item, 'copy', passcode);
258
+ if (guard.stop) {
259
+ actor.performAction('edits a file', `${item.name} in ${hostLabel(host.over)}`, { quiet: this.scene.isHumanControlled(actor) });
260
+ return { ok: false, text: guard.lines.join('\n') };
261
+ }
262
+ if (guard.lines.length > 0)
263
+ blast = `${guard.lines.join('\n')}\n`;
264
+ }
248
265
  if (host && needsRoll) {
249
266
  const roll = rollEditFile(this.scene, this.logger, actor, host, 'copy');
250
267
  if (!roll.success) {
251
268
  actor.performAction('edits a file', `${item.name} in ${hostLabel(host.over)}`, { quiet: this.scene.isHumanControlled(actor) });
252
269
  this.logger.write(`${actor.name} failed to copy "${item.name}" from ${host.over.name}: ${roll.hits} v ${roll.defence}.`);
253
- return { ok: false, text: `The copy stalls -- ${hostLabel(host.over)}'s checksum rejects the read and nothing comes down. A failed edit is not a failed sleaze; nobody upstairs noticed.${hint(` (Try again; each attempt is a Complex Action.)`)}` };
270
+ return { ok: false, text: `${blast}The copy stalls -- ${hostLabel(host.over)}'s checksum rejects the read and nothing comes down. A failed edit is not a failed sleaze; nobody upstairs noticed.${hint(` (Try again; each attempt is a Complex Action.)`)}` };
254
271
  }
255
272
  }
256
273
  // THE MOVE. A sprite's file is already yours and moves; everything
@@ -281,11 +298,11 @@ export class DownloadCommand extends Command {
281
298
  this.logger.write(`${actor.name} downloaded "${item.name}" (${source.kind}) ${onto ? `onto ${onto.name}` : 'into its own hands'}.`);
282
299
  this.game?.requestSave('data');
283
300
  this.scene.updateStatus();
284
- const text = onto
301
+ const text = blast + (onto
285
302
  ? source.kind === 'sprite'
286
303
  ? `{light-blue-fg}${source.npc.name} opens its hands and the data pours out of them -- {bold}{underline}${item.name}{/underline}{/bold} is on your ${onto.name} now.{/light-blue-fg}`
287
304
  : `{light-blue-fg}You copy {bold}{underline}${item.name}{/underline}{/bold} onto your ${onto.name}${host ? ` -- ${hostLabel(host.over)} keeps its original` : ''}.{/light-blue-fg}${hint(` ("give" or "call" hands it over straight from the device; "transfer ${item.name} to chip" writes it to physical media.)`)}`
288
- : `{light-blue-fg}You take hold of ${item.name}.{/light-blue-fg}`;
305
+ : `{light-blue-fg}You take hold of ${item.name}.{/light-blue-fg}`);
289
306
  // 'possess' objectives complete in the runner's hands; a device library
290
307
  // is the runner's hands for data (scene.ts checkWinCondition).
291
308
  if (this.scene.isHumanControlled(actor))
@@ -4,6 +4,7 @@ import { billAction } from '../utilities/action-cost.js';
4
4
  import { editFileBlocked, rollEditFile } from '../utilities/edit-file-test.js';
5
5
  import { hostLabel } from '../utilities/grid-names.js';
6
6
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
7
+ import { guardFile, splitPasscode } from '../utilities/file-guard.js';
7
8
  /**
8
9
  * EDIT FILE (SR5 p.239, RAG-checked 2026-09-14): a Complex Action, Marks
9
10
  * Required 1, Computer + Logic [Data Processing] v. Intuition + Firewall.
@@ -87,7 +88,9 @@ export class EditFileCommand extends Command {
87
88
  }
88
89
  const room = host.over;
89
90
  const files = host.files();
90
- const query = rest.join(' ').trim();
91
+ // "erase <file> with <passcode>" walks past its data bomb (p.242).
92
+ const { rest: named, passcode } = splitPasscode(rest);
93
+ const query = named.join(' ').trim();
91
94
  if (!query) {
92
95
  return files.length === 0
93
96
  ? `${hostLabel(room, { capital: true })} keeps nothing here to edit.`
@@ -119,20 +122,29 @@ export class EditFileCommand extends Command {
119
122
  const bill = billAction(this.scene, actor, 'complex', 'Edit File');
120
123
  if (bill)
121
124
  return bill;
125
+ // THE FILE'S OWN DEFENCES (utilities/file-guard.ts): the attempt
126
+ // springs a bomb, and protection refuses the delete outright.
127
+ const guard = guardFile(this.scene, this.logger, actor, host, file, 'delete', passcode);
128
+ if (guard.stop) {
129
+ actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
130
+ return guard.lines.join('\n');
131
+ }
132
+ const blast = guard.lines.length > 0 ? `${guard.lines.join('\n')}
133
+ ` : '';
122
134
  // The roll is utilities/edit-file-test.ts, shared with download (copy)
123
135
  // and upload (create) so the family cannot drift apart.
124
136
  const attempt = rollEditFile(this.scene, this.logger, actor, host, 'delete');
125
137
  actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
126
138
  if (!attempt.success) {
127
139
  this.logger.write(`${actor.name} failed to delete "${file.name}" in ${room.name}: ${attempt.hits} v ${attempt.defence}.`);
128
- return `The file's checksum holds -- your edit rolls off it and the archive keeps ${file.name}. Nothing upstairs noticed: a failed edit is not a failed sleaze.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
140
+ return `${blast}The file's checksum holds -- your edit rolls off it and the archive keeps ${file.name}. Nothing upstairs noticed: a failed edit is not a failed sleaze.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
129
141
  }
130
142
  host.removeFile(file.name);
131
143
  this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${attempt.defence}.`);
132
144
  this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`, { plane: 'matrix' });
133
145
  const banner = this.scene.checkErased?.(file.name, actor.name);
134
146
  this.scene.updateStatus();
135
- return [
147
+ return blast + [
136
148
  `{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}`,
137
149
  ...(banner ? [banner] : []),
138
150
  ].join('\n');
@@ -15,7 +15,7 @@ import { hint } from '../utilities/hints.js';
15
15
  import { gridIconBlock } from '../utilities/grid-view.js';
16
16
  import { requirePhase } from '../utilities/action-cost.js';
17
17
  import { midClimbRefusal } from '../utilities/climb-state.js';
18
- import { camerasLive } from '../utilities/surveillance.js';
18
+ import { cameraSeeing } from '../utilities/surveillance.js';
19
19
  import { maybeSinSweep } from '../utilities/sin.js';
20
20
  import { despawnEphemerals } from '../utilities/ephemeral.js';
21
21
  import { maybeStreetGang } from '../utilities/street-gang.js';
@@ -25,9 +25,11 @@ import { Direction } from '../types/shared/direction-enum.js';
25
25
  import { dragFollowers } from './follow.js';
26
26
  import { escortedBy } from './bring.js';
27
27
  import { NPC } from '../models/npc.js';
28
+ import { caughtAtDoor, springDoorWatch } from '../utilities/door-watch.js';
28
29
  import { Command } from './command.js';
29
30
  import { doorwayOf, streetDoors } from '../utilities/district-plane.js';
30
31
  import { driftAstral, driftRefusal } from '../utilities/astral-move.js';
32
+ import { pressThroughWard, spendWardPass, wardBetween } from '../utilities/wards.js';
31
33
  import { isOutdoorsPlace } from '../utilities/outdoors.js';
32
34
  import { crowdLine } from '../utilities/crowd.js';
33
35
  import { startPathRun, goalReached, routeMeters, planSegment } from '../utilities/path-run.js';
@@ -571,7 +573,21 @@ export class GoCommand extends Command {
571
573
  */
572
574
  if (this.actor.plane === 'astral' && this.isValidDirection(direction)
573
575
  && DRIFT_WAYS.has(direction)) {
574
- const drift = driftAstral(this.scene, this.actor, this.rooms ?? {}, direction, 1, true);
576
+ let drift = driftAstral(this.scene, this.actor, this.rooms ?? {}, direction, 1, true);
577
+ // A WARD IN THE WAY IS PRESSED, NOT JUST REFUSED (p.316): `go` is
578
+ // the verb that means to get there, so it makes the attempt.
579
+ if (drift !== undefined && drift.taken === 0 && drift.blockedBy === 'ward') {
580
+ const ward = Object.values(this.rooms ?? {}).find(r => r.name === drift.ward);
581
+ if (ward) {
582
+ const press = pressThroughWard(this.scene, this.logger, this.actor, ward);
583
+ if (this.scene.isHumanControlled(this.actor))
584
+ this.logger.meta(press.meta);
585
+ if (!press.through) {
586
+ return `The ward over ${ward.name} holds -- Force ${ward.wardForce}. You press against the lattice and it presses back; the astral does not pass.`;
587
+ }
588
+ drift = driftAstral(this.scene, this.actor, this.rooms ?? {}, direction, 1, true);
589
+ }
590
+ }
575
591
  if (drift !== undefined) {
576
592
  if (drift.taken === 0)
577
593
  return driftRefusal(drift, direction);
@@ -691,6 +707,21 @@ export class GoCommand extends Command {
691
707
  // either one holds the way -- there is no route around a door the
692
708
  // way there is around a table. Checked BEFORE the walk, so you're
693
709
  // told who's in your way instead of crossing the room to find out.
710
+ // A GUARDED DOOR (utilities/door-watch.ts): whoever watches it -- or
711
+ // holds it, which watches it too -- and catches the runner going for
712
+ // it turns hostile, and the fight opens here; the move does not
713
+ // happen. A runner who slips past unseen carries on. Runners only:
714
+ // NPCs walking the site do not spring each other's watch.
715
+ if (!(this.actor instanceof NPC)) {
716
+ const watched = caughtAtDoor(currentRoom, direction, this.actor, this.scene.isHumanControlled(this.actor));
717
+ if (this.scene.isHumanControlled(this.actor))
718
+ for (const line of watched.lines)
719
+ this.logger.meta(line);
720
+ if (watched.npc) {
721
+ this.actor.performAction('is caught going for the door', `${watched.npc.name} watches the ${direction} door`);
722
+ return await springDoorWatch(this.scene, currentRoom, direction, this.actor, watched.npc);
723
+ }
724
+ }
694
725
  const blocker = doorwayBlocker(currentRoom, direction, this.actor);
695
726
  if (blocker) {
696
727
  this.actor.performAction('finds the way blocked', `${blocker.name} holds the ${direction} doorway`);
@@ -898,6 +929,7 @@ export class GoCommand extends Command {
898
929
  this.actor.performAction('exits', `${currentRoom.name} to ${targetRoom.name}`);
899
930
  this.actor.cameFrom = { room: currentRoom.name, back: GoCommand.opposite(travelled) };
900
931
  this.actor.currentLocation = targetRoom;
932
+ spendWardPass(this.actor);
901
933
  // "At" spots: an arrival stands at the room's entry (the setter
902
934
  // cleared any stale spot), and the WITNESS event says where -- so
903
935
  // an NPC's history reads "enters the bar (at the door)" and its AI
@@ -1106,7 +1138,7 @@ export class GoCommand extends Command {
1106
1138
  let camLine = '';
1107
1139
  if (this.game?.onRun && this.actor === this.scene.getPlayer()
1108
1140
  && this.actor.qualities.has('sinner-criminal')
1109
- && camerasLive(this.rooms ?? {}, room)
1141
+ && !!cameraSeeing(this.rooms ?? {}, this.actor)
1110
1142
  && !this.game.camPingedRooms.has(room.name)) {
1111
1143
  this.game.camPingedRooms.add(room.name);
1112
1144
  this.game.addHeat(1, 'facial rec: criminal SIN on camera', this.actor, { identity: true });
@@ -1237,21 +1269,27 @@ export class GoCommand extends Command {
1237
1269
  const map = { north: 'south', south: 'north', east: 'west', west: 'east', up: 'down', down: 'up' };
1238
1270
  return map[direction] ?? direction;
1239
1271
  }
1240
- /** Wards block astral forms and nothing else. Returns the refusal, or
1241
- * null. MASKING (metamagic, p.325-326, adapted): a masked aura reads as
1242
- * mundane background -- the ward has nothing to flare at, and the
1243
- * initiate slips through like weather. */
1272
+ /**
1273
+ * WARDS STOP ASTRAL FORMS AND NOTHING ELSE (p.294, p.315), both ways --
1274
+ * into a warded room and out of one. Crossing one is a press (p.316):
1275
+ * Magic + Charisma [Astral] v. Force x 2, and a breach is something the
1276
+ * creator knows (utilities/wards.ts). Its creator crosses freely.
1277
+ *
1278
+ * No Masking slip any more: Masking hides an aura from Assensing and
1279
+ * crosses nothing (SG p.149). Fooling Wards (SG p.135) needs Flux or an
1280
+ * astral doppelganger, which this engine does not model.
1281
+ */
1244
1282
  checkWard(target) {
1245
- if (this.actor.plane === 'astral' && target.warded) {
1246
- if (this.actor.hasMetamagic('masking')) {
1247
- this.actor.performAction('slips a ward', target.name);
1248
- if (this.scene.isHumanControlled(this.actor)) {
1249
- this.logger.logWithColor(`The ward tastes your aura and finds... weather. Masking carries you through.`, 'magenta');
1250
- }
1251
- return null;
1252
- }
1253
- this.actor.performAction('is repelled by a ward', target.name);
1254
- return `A ward seals ${target.name} -- the barrier flares at your touch and holds. No astral form passes without tearing it down.`;
1283
+ if (this.actor.plane !== 'astral')
1284
+ return null;
1285
+ const ward = wardBetween(this.actor.currentLocation, target, this.actor);
1286
+ if (!ward)
1287
+ return null;
1288
+ const press = pressThroughWard(this.scene, this.logger, this.actor, ward);
1289
+ if (this.scene.isHumanControlled(this.actor))
1290
+ this.logger.meta(press.meta);
1291
+ if (!press.through) {
1292
+ return `A ward seals ${ward.name} -- Force ${ward.wardForce}. You press against the lattice and it presses back; the astral does not pass${hint(` ("attack ward" tears at it instead)`)}.`;
1255
1293
  }
1256
1294
  return null;
1257
1295
  }
@@ -16,6 +16,7 @@ import { MARKS_TO_CONTROL } from './disable.js';
16
16
  import { nameableNames } from '../utilities/nameables.js';
17
17
  import { sameHostSide } from '../models/player.js';
18
18
  import { MAX_MARKS, markHackerFromDevice } from '../utilities/marks.js';
19
+ import { ownerInformed } from '../utilities/owner-guard.js';
19
20
  import { actionRefusal, billAction } from '../utilities/action-cost.js';
20
21
  import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
21
22
  import { declarationPenalty, parseMarkDeclaration, hostDefensePool, wanDefensePool, freeMatrixPerceptionHits, bruteForceMatrixDv, overwatchFromDefense, GO_BIG_QUALITY, deviceDefensePool } from '../utilities/matrix-intrusion.js';
@@ -323,9 +324,12 @@ export class HackCommand extends BypassCommand {
323
324
  // exact moment they most need to know, since a blown Sleaze is
324
325
  // what hands a mark to something that did not have one.
325
326
  const marksOnMe = room.hostMarksOn.get(this.actor.name) ?? 0;
327
+ // ...and its OWNER is told (owner-guard.ts; p.236).
328
+ const told = ownerInformed(this.scene, room, this.actor, 'host-sleaze', hostLabel(room, { capital: true }));
326
329
  return [
327
330
  `${hostLabel(room, { capital: true })} turns and LOOKS AT YOU -- your Hack on the Fly unravels mid-handshake.`,
328
331
  ` It holds ${marksOnMe === 1 ? 'a mark' : `${marksOnMe} marks`} on your persona now, it can see you, and everything guarding this place knows someone is on the grid. ("unmark me" scrubs it; so do "reboot" and "jack out", p.242.)`,
332
+ ...(told ? [` ${told}`] : []),
329
333
  ...godLines,
330
334
  ].join('\n');
331
335
  }
@@ -410,6 +414,9 @@ export class HackCommand extends BypassCommand {
410
414
  this.actor.sneaking = false;
411
415
  room.hostAlert = true;
412
416
  this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} takes a forced key -- alarm flagged, ice hunting.`, { plane: 'matrix' });
417
+ const told = ownerInformed(this.scene, room, this.actor, 'attack-landed', hostLabel(room, { capital: true }));
418
+ if (told)
419
+ lines.push(` ${told}`);
413
420
  }
414
421
  lines.push(...godLines);
415
422
  this.scene.updateStatus();
@@ -507,6 +514,9 @@ export class HackCommand extends BypassCommand {
507
514
  // under attack and screams to its owner -- it just can't see
508
515
  // WHOSE hand did it).
509
516
  this.actor.sneaking = false;
517
+ const told = ownerInformed(this.scene, room, this.actor, 'attack-landed', hostLabel(room, { capital: true }));
518
+ if (told)
519
+ lines.push(` ${told}`);
510
520
  // A SUCCESSFUL ATTACK ACTION WAKES THE HOST (p.236: the target
511
521
  // "becomes aware that it is under attack" though it does not
512
522
  // automatically spot you; p.247: a host that notices something
@@ -765,7 +775,8 @@ export class HackCommand extends BypassCommand {
765
775
  actor.sneaking = false;
766
776
  // ...and it marks you (p.236) -- the trace a blown Sleaze leaves.
767
777
  const onYou = markHackerFromDevice(actor, device.name, host);
768
- const trace = ` It holds ${onYou} mark${onYou === 1 ? '' : 's'} on your deck now${hint(' ("mark" shows who holds you; a reboot or "unmark me" shakes it)')}.`;
778
+ const toldOwner = ownerInformed(this.scene, Object.values(allRooms).find(r => r.devices.includes(device)), actor, 'device-sleaze', device.name);
779
+ const trace = ` It holds ${onYou} mark${onYou === 1 ? '' : 's'} on your deck now${hint(' ("mark" shows who holds you; a reboot or "unmark me" shakes it)')}.${toldOwner ? ` ${toldOwner}` : ''}`;
769
780
  if (host) {
770
781
  host.hostAlert = true;
771
782
  this.scene.addWorldEvent(`${device.name} flags a bad handshake -- ${hostLabel(host, { capital: true })} is awake.`, { plane: 'matrix' });
@@ -810,6 +821,9 @@ export class HackCommand extends BypassCommand {
810
821
  if (host)
811
822
  host.hostAlert = true;
812
823
  actor.sneaking = false;
824
+ const told = ownerInformed(this.scene, Object.values(allRooms).find(r => r.devices.includes(device)), actor, 'attack-landed', device.name);
825
+ if (told)
826
+ lines.push(` ${told}`);
813
827
  }
814
828
  else {
815
829
  const perception = freeMatrixPerceptionHits(netHits);
@@ -0,0 +1,23 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { ledgeDirection, ledgeJump } from '../utilities/ledge-jump.js';
4
+ /**
5
+ * "leap off <direction>" (user ruling 2026-10-06): going over the edge of
6
+ * a balcony, catwalk or rooftop on purpose. The extra word is the safety
7
+ * catch -- a fall is never one mistyped direction away -- so a bare
8
+ * "leap north" is refused and told the full form. utilities/ledge-jump.ts
9
+ * does the drop and the landing.
10
+ */
11
+ export class LeapCommand extends Command {
12
+ static verb = 'leap';
13
+ static description = 'Go over the edge of a balcony, catwalk or rooftop on purpose: "leap off <north|south|east|west>". You land on the outdoor ground below that spot and take the fall (SR5 p.172: more than 3 m is DV = metres, AP -4, Body + Armor).';
14
+ async execute(args = []) {
15
+ const [off, dirWord] = args.map(a => a.toLowerCase());
16
+ const dir = ledgeDirection(dirWord);
17
+ if (off !== 'off' || !dir) {
18
+ return `Leap where? ${hint(`"leap off <north|south|east|west>" -- the word off is on purpose: a drop is never one word away.`)}`;
19
+ }
20
+ return ledgeJump(this.scene, this.actor, dir);
21
+ }
22
+ }
23
+ //# sourceMappingURL=leap.js.map
@@ -507,6 +507,14 @@ export class LookCommand extends Command {
507
507
  const onDevice = findDeviceFile(this.actor, target);
508
508
  if (onDevice)
509
509
  return this.describeItem(onDevice.file, target);
510
+ // A FILE IN THE ARCHIVE YOU STAND IN, once it is found: Matrix
511
+ // Perception on the file (p.241-242) -- what turns up a data bomb.
512
+ const inHost = this.actor.hostInside;
513
+ if (inHost && (inHost.searchedBy.has(this.actor.name) || inHost.over.hostCracked)) {
514
+ const file = inHost.files().find(f => f.name.toLowerCase() === target.toLowerCase());
515
+ if (file)
516
+ return this.perceiveFile(file, target);
517
+ }
510
518
  const onGrid = room.inventory.getItem(target);
511
519
  if (onGrid?.plane === 'matrix') {
512
520
  const reach = fileReach(this.actor, room);
@@ -774,6 +782,38 @@ export class LookCommand extends Command {
774
782
  }
775
783
  /** The item's prose and stat block -- the same read for a thing in the
776
784
  * bag, on the body, or on the floor. */
785
+ /**
786
+ * MATRIX PERCEPTION ON A HOST FILE (p.241; p.242: "a Data Bomb can be
787
+ * detected using Matrix Perception"). Computer + Intuition [Data
788
+ * Processing], a Complex Action; a bomb does not run silent, so it is
789
+ * not an opposed test -- one hit turns it up (p.235: each hit buys a
790
+ * piece of information). Protection needs no test: the file is a
791
+ * sealed block on its face, and the book's own refusal (p.239) tells
792
+ * anyone who tries.
793
+ */
794
+ perceiveFile(file, itemName) {
795
+ const bill = billAction(this.scene, this.actor, 'complex', 'Matrix Perception');
796
+ if (bill)
797
+ return bill;
798
+ const lines = [this.describeItem(file, itemName)];
799
+ if (file.protection > 0)
800
+ lines.push(`{light-blue-fg} PROTECTED -- encrypted at rating ${file.protection}. "crack ${file.name}" breaks it (p.238).{/light-blue-fg}`);
801
+ if (file.dataBomb && !file.bombSeenBy.has(this.actor.name)) {
802
+ const computer = this.actor.skillRating('computer');
803
+ const pool = Math.max(1, this.actor.intuition + (computer > 0 ? computer : -1)
804
+ + this.actor.matrixActionPenalty + this.actor.woundModifier - this.actor.sustainingPenalty);
805
+ const roll = rollPool(pool, this.actor.matrixAttribute('dataProcessing'), { gremlins: this.actor.deckGremlins });
806
+ if (showsMechanics(this.scene, this.actor)) {
807
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Matrix Perception on ${file.name} -- Computer + Intuition [Data Processing]: ${formatRoll(roll)}`);
808
+ }
809
+ if (roll.hits > 0)
810
+ file.bombSeenBy.add(this.actor.name);
811
+ }
812
+ if (file.dataBomb && file.bombSeenBy.has(this.actor.name)) {
813
+ lines.push(`{red-fg} DATA BOMB -- rating ${file.dataBomb.rating}${file.dataBomb.deletesFile ? ', set to take the file with it' : ''}. Touch it without the passcode and it goes off. "disarm ${file.name}" (p.239).{/red-fg}`);
814
+ }
815
+ return lines.join('\n');
816
+ }
777
817
  describeItem(item, itemName) {
778
818
  // A CODE ON THE PAGE IS A PERCEPTION TEST (Xxbwtgffkyq8uCHfR): a
779
819
  // scrap of paper with a combination on it is read here, not by