@maka/maka-cli 5.391.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 (67) 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 +13 -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 +49 -3
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/spider.js +50 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +14 -4
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +193 -8
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +1 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +2 -2
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +116 -0
  67. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.391.0",
3
+ "version": "5.392.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.",
@@ -121,7 +121,7 @@ export class AssenseCommand extends Command {
121
121
  const room = actor.currentLocation;
122
122
  const lines = [header];
123
123
  lines.push(room.warded
124
- ? ` • This place is WARDED -- an astral barrier seals it against projection.`
124
+ ? ` • This place is WARDED -- an astral barrier of Force ${room.wardForce} seals it against astral forms (bodies walk through it unhindered).`
125
125
  : ` • No wards here -- the astral is open ground.`);
126
126
  if (room.hasNode) {
127
127
  lines.push(` • The room's tech hums with dataflow -- a live Matrix node (nothing astral, but a decker would care).`);
@@ -11,11 +11,13 @@ import { distanceMeters } from '../utilities/room-grid.js';
11
11
  import { wallsBetween } from '../utilities/district-plane.js';
12
12
  import { formatCoord, parseCoord } from '../utilities/barrier-geometry.js';
13
13
  import { resolveBarrierStrike, resolveCameraStrike, resolveSpellBarrierStrike } from '../utilities/barrier-combat.js';
14
+ import { resolveWardStrike } from '../utilities/wards.js';
14
15
  import { isWatched, cameraWithinReach, cameraOutOfReach } from '../utilities/surveillance.js';
15
16
  import { matchesCameraName } from '../utilities/grid-names.js';
16
17
  import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
17
18
  import { arenaOf, inArena } from '../utilities/combat-turn.js';
18
19
  import { spendMovementMeters } from '../utilities/movement-cost.js';
20
+ import { ownerWitnesses } from '../utilities/owner-guard.js';
19
21
  /**
20
22
  * Shadowrun-style dice-pool combat, one full exchange per command:
21
23
  *
@@ -50,7 +52,7 @@ export class AttackCommand extends Command {
50
52
  async readyForStrike() {
51
53
  return {};
52
54
  }
53
- 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). "attack barrier [x,y]" breaks a face of a barrier spell.';
55
+ 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). "attack barrier [x,y]" breaks a face of a barrier spell; from the astral, "attack ward" tears at a ward (Armor and Structure = its Force, p.316).';
54
56
  async execute(args = []) {
55
57
  if (!args || args.length === 0) {
56
58
  return 'Who do you want to attack? Try: `attack <name>`';
@@ -155,12 +157,36 @@ export class AttackCommand extends Command {
155
157
  });
156
158
  const lines = [...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
157
159
  this.actor.performAction('attacks', `the ${lens.name}`);
160
+ // Their camera, smashed in front of them (owner-guard.ts).
161
+ const seenLens = ownerWitnesses(this.scene, room, this.actor, `the ${lens.name}`);
162
+ if (seenLens)
163
+ lines.push(seenLens);
158
164
  this.scene.updateStatus();
159
165
  const exchange = new CombatExchange(this.scene, this.logger, this.actor);
160
166
  await exchange.announce(lines, strike.meta);
161
167
  return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
162
168
  }
163
169
  }
170
+ // A WARD (p.316): "attack ward" tears at the ward over the room you
171
+ // are in, or a warded room next to it ("attack ward over <room>").
172
+ // Astral only: to a bullet a ward is not there at all.
173
+ const wardWords = /^(?:the\s+)?ward(?:\s+(?:over|on|of|at)\s+(.+))?$/i.exec(targetName.trim());
174
+ if (wardWords) {
175
+ const picked = this.wardTarget(wardWords[1]);
176
+ if (typeof picked === 'string')
177
+ return picked;
178
+ if (this.scene.encounterFor?.(this.actor)) {
179
+ const bill = billAction(this.scene, this.actor, 'complex', 'Attack Ward', { attack: true });
180
+ if (bill)
181
+ return bill;
182
+ }
183
+ const strike = resolveWardStrike(this.scene, this.logger, this.actor, picked);
184
+ this.actor.performAction('attacks', `the ward over ${picked.name}`);
185
+ this.scene.updateStatus();
186
+ const exchange = new CombatExchange(this.scene, this.logger, this.actor);
187
+ await exchange.announce(strike.world, strike.meta);
188
+ return this.scene.isHumanControlled(this.actor) ? '' : strike.world.join('\n');
189
+ }
164
190
  // A BARRIER SPELL (2026-10-05; p.197-198, p.294, p.316): "attack
165
191
  // barrier" hits the nearest standing face you can see, "attack
166
192
  // barrier 3,5" the face holding that cell. A Physical Barrier is
@@ -235,6 +261,10 @@ export class AttackCommand extends Command {
235
261
  });
236
262
  const lines = [...(reach.line ? [reach.line] : []), ...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
237
263
  this.actor.performAction('attacks', device.name);
264
+ // Their lock, hit in front of them (owner-guard.ts).
265
+ const seenDevice = ownerWitnesses(this.scene, room, this.actor, device.name);
266
+ if (seenDevice)
267
+ lines.push(seenDevice);
238
268
  this.scene.updateStatus();
239
269
  const exchange = new CombatExchange(this.scene, this.logger, this.actor);
240
270
  await exchange.announce(lines, strike.meta);
@@ -601,6 +631,35 @@ export class AttackCommand extends Command {
601
631
  }
602
632
  return null;
603
633
  }
634
+ /**
635
+ * THE WARD THIS FORM CAN REACH: the one over the room it is in (sealed
636
+ * in), else a warded room through one of its ways (kept out). Named
637
+ * when there is more than one. A refusal string when there is none, or
638
+ * when the attacker is not on the astral.
639
+ */
640
+ wardTarget(named) {
641
+ if (this.actor.plane !== 'astral') {
642
+ return `A ward is woven mana on the astral -- ${this.actor.getCarriedWeapon()?.name ?? 'your fists'} would pass through it as if it were not there. Only an astral form can touch it (p.315).`;
643
+ }
644
+ const here = this.actor.currentLocation;
645
+ const near = here.warded ? [here] : [];
646
+ for (const exit of here.exits.values()) {
647
+ const other = typeof exit.getOtherSide === 'function'
648
+ ? exit.getOtherSide(here) : undefined;
649
+ if (other?.warded && !near.includes(other))
650
+ near.push(other);
651
+ }
652
+ if (named) {
653
+ const pick = fuzzyPickName(named, near.map(r => r.name));
654
+ const ward = pick ? near.find(r => r.name === pick) : undefined;
655
+ return ward ?? `No ward over "${named}" within reach.${near.length > 0 ? ` In reach: ${near.map(r => r.name).join(', ')}.` : ''}`;
656
+ }
657
+ if (near.length === 0)
658
+ return `No ward within reach -- the astral here is open ground.`;
659
+ if (near.length > 1 && !here.warded)
660
+ return `Which ward? ${near.map(r => `"attack ward over ${r.name}"`).join(', ')}.`;
661
+ return near[0];
662
+ }
604
663
  /**
605
664
  * "barrier", "the force wall", "mana barrier 3,5" -> the standing face
606
665
  * this actor would hit, or a refusal that says why not, or undefined
@@ -41,7 +41,7 @@ import { AFFORDANCE_FLAVOR } from '../utilities/affordances.js';
41
41
  */
42
42
  export class BluffCommand extends Command {
43
43
  static verb = 'bluff';
44
- static description = 'Talk a PERSON round -- Charisma + con. Locks, wards and terminals have no opinion to change; work those with "pick", "hack" or "cast".';
44
+ static description = 'Talk a PERSON round -- Charisma + con. Locks and terminals have no opinion to change; work those with "pick" or "hack".';
45
45
  async execute(args = []) {
46
46
  // Fast-talk needs a face and a voice on the scene -- your meat ones.
47
47
  if (this.actor.plane === 'matrix') {
@@ -2,6 +2,7 @@ import { BypassCommand } from './bypass-command.js';
2
2
  import { Door } from '../models/door.js';
3
3
  import { ensureAtExit } from '../utilities/spots.js';
4
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
5
+ import { ownerWitnesses } from '../utilities/owner-guard.js';
5
6
  /**
6
7
  * The FORCEFUL route past a physical barrier (player ruling: "solve" was
7
8
  * too broad -- a maglock wants finesse OR muscle, and muscle deserves its
@@ -119,7 +120,8 @@ export class BreachCommand extends BypassCommand {
119
120
  const where = String(direction).toLowerCase();
120
121
  if (!gave) {
121
122
  this.actor.performAction('throws a shoulder at', `the ${where} door, and it holds`);
122
- return `${crossNote}${this.failureFlavor}`;
123
+ const seen = ownerWitnesses(this.scene, room, this.actor, `the ${where} door`);
124
+ return `${crossNote}${this.failureFlavor}${seen ? `\n ${seen}` : ''}`;
123
125
  }
124
126
  door.isLocked = false;
125
127
  this.actor.sneaking = false;
@@ -128,7 +130,8 @@ export class BreachCommand extends BypassCommand {
128
130
  this.scene.raiseAlarm(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried`);
129
131
  this.scene.addWorldEvent(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried.`);
130
132
  this.game?.addHeat(2, `breached the ${where} door`);
131
- return `${crossNote}${this.successFlavor}\n The crash CARRIES -- anyone nearby knows exactly where you are now.`;
133
+ const seen = ownerWitnesses(this.scene, room, this.actor, `the ${where} door`);
134
+ return `${crossNote}${this.successFlavor}\n The crash CARRIES -- anyone nearby knows exactly where you are now.${seen ? `\n ${seen}` : ''}`;
132
135
  }
133
136
  async determineOutcome() {
134
137
  // Raw muscle -- Strength + Body, 2 hits to give. No skill governs
@@ -1,6 +1,7 @@
1
1
  import { Command } from './command.js';
2
2
  import { ensureAtSpot, ensureAtExit } from '../utilities/spots.js';
3
3
  import { applyDeviceOpened } from '../utilities/devices.js';
4
+ import { ownerWitnesses } from '../utilities/owner-guard.js';
4
5
  /**
5
6
  * Shared implementation for pick/breach/hack/cast/dispel -- FORCING a
6
7
  * device rather than opening it the way it was meant to open.
@@ -257,6 +258,11 @@ export class BypassCommand extends Command {
257
258
  }
258
259
  this.logger.write(`${this.actor.name} attempted to ${this.verbName} "${device.name}": ${result.success ? 'success' : 'failure'}${result.alarmed ? ' (alarmed)' : ''}`);
259
260
  this.actor.performAction(this.verbName, result.message);
261
+ // THE OWNER WATCHING (owner-guard.ts): picking, breaching or forcing
262
+ // their lock in front of them turns them. A key or a keycard is the
263
+ // legitimate way through and is not a hostile act.
264
+ const seen = this.verbName === 'use' ? undefined : ownerWitnesses(this.scene, room, this.actor, device.name);
265
+ const ownerLine = seen ? `\n ${seen}` : '';
260
266
  if (result.alarmed) {
261
267
  this.actor.sneaking = false;
262
268
  const teeth = await this.onForceFailure();
@@ -268,9 +274,9 @@ export class BypassCommand extends Command {
268
274
  // promising a permanence that no longer exists.
269
275
  ` It holds. You can try again, but the noise has already carried.`,
270
276
  ...teeth,
271
- ].join('\n');
277
+ ].join('\n') + ownerLine;
272
278
  }
273
- return crossNote + [result.message, ...consequence].join('\n');
279
+ return crossNote + [result.message, ...consequence].join('\n') + ownerLine;
274
280
  }
275
281
  }
276
282
  //# sourceMappingURL=bypass-command.js.map
@@ -9,7 +9,7 @@ import { isOutdoorsPlace } from '../utilities/outdoors.js';
9
9
  import { readEarshot } from '../utilities/earshot.js';
10
10
  import { firstReachable } from '../utilities/spots.js';
11
11
  import { meetLocationFor } from '../utilities/meet-location.js';
12
- import { partyCanDeck, partyCanDispel } from '../factories/scene-chunks.js';
12
+ import { partyCanDeck } from '../factories/scene-chunks.js';
13
13
  import { requirementLine } from '../factories/job-order.js';
14
14
  import { readJobAsk, fixerMenuLine, fixerPickupLine, fixerPickupNote, commissionFromFixer } from '../utilities/fixer-menu.js';
15
15
  import { RecallCommand } from './companions.js';
@@ -543,7 +543,9 @@ export class CallCommand extends Command {
543
543
  // against the caller, not the crew -- a hired seat's gear is
544
544
  // not knowable from the dossier, so the line says "put one on
545
545
  // the crew" and lets the player judge whether they already did.
546
- const lacks = (pending.requires ?? []).filter((r) => r === 'decker' ? !partyCanDeck(this.actor) : !partyCanDispel(this.actor));
546
+ // A 'mage' an older pending job still carries is dropped: it was
547
+ // the door-ward requirement, and a ward stops no body any more.
548
+ const lacks = (pending.requires ?? []).filter((r) => r === 'decker' && !partyCanDeck(this.actor));
547
549
  const needsLine = requirementLine(lacks);
548
550
  // WETWORK IS SAID OUT LOUD (user ruling 2026-09-24; SR5 p.336:
549
551
  // "one or more runners refuse 'wetwork'"). The flag is
@@ -1,5 +1,5 @@
1
1
  import { sustainBurden } from '../utilities/sustaining.js';
2
- import { BypassCommand } from './bypass-command.js';
2
+ import { Command } from './command.js';
3
3
  import { hint } from '../utilities/hints.js';
4
4
  import { CombatExchange } from '../utilities/combat-exchange.js';
5
5
  import { rollPool, formatRoll, rollD6Total } from '../utilities/dice.js';
@@ -87,125 +87,29 @@ const COMBAT_SPELL_FLAVOR = {
87
87
  * Gear-driven spellcasting (see Player's magic section): you know a spell
88
88
  * if you carry its SpellFormula (or any Grimoire), and the best Focus on
89
89
  * you scales the casting pool. "cast <spell> [at <target>]" casts; a bare
90
- * "cast" (or "cast <direction/barrier>") is still the ward-bypass route
91
- * inherited from BypassCommand -- now gated on actually having magic gear,
92
- * instead of being a free-floating coin flip anyone could attempt.
90
+ * A WARD IS NOT CAST AT (user 2026-10-06): it is an astral barrier over a
91
+ * room (utilities/wards.ts), pressed through or attacked from the astral --
92
+ * never a door this verb unweaves.
93
93
  */
94
94
  /**
95
95
  * BLIND FIRE (p.178): "the same as the Total Darkness modifier", -6 -- the
96
96
  * number p.197 gives for a target completely hidden from the attacker.
97
97
  */
98
98
  const BLIND_FIRE = -6;
99
- export class CastCommand extends BypassCommand {
99
+ export class CastCommand extends Command {
100
100
  static verb = 'cast';
101
101
  /** Touches another actor: authorized per-act for AI-driven actors
102
102
  * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
103
103
  static npcPolicy = 'authorized';
104
- 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. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". Area spells too: "cast fireball 4 at 12,7" (a point) or "cast fireball 4 at <someone>" -- Force m around it, friend and foe. A ward is "dispel <direction>".';
105
- deviceKind = 'ward';
106
- verbName = 'cast';
104
+ 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. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". Area spells too: "cast fireball 4 at 12,7" (a point) or "cast fireball 4 at <someone>" -- Force m around it, friend and foe.';
107
105
  /** The bolt in flight is at someone the caster cannot see (castCombatSpell). */
108
106
  unseenTarget = false;
109
- noDeviceMessage = "There's nothing here to cast at.";
110
- /** Backlash drain when a ward-bypass fails -- resisted with Willpower,
111
- * like any drain. The nastiest drain in the game: forcing a hostile
112
- * ward is harder on the caster than any spell of their own. */
113
- static BACKLASH_DRAIN = 4;
114
- /**
115
- * The ward-bypass outcome is a real SPELLCASTING test now, not the
116
- * legacy coin flip (a real session died to a 50/50 that ignored Magic 6
117
- * + spellcasting entirely): the full casting pool -- Magic +
118
- * spellcasting (or the book-caster's base), best focus, wounds,
119
- * sustaining -- needs 2 hits to unweave the barrier, same threshold as
120
- * pick/hack/bluff.
121
- */
122
- async determineOutcome() {
123
- const pool = Math.max(1, this.actor.getMagicPool());
124
- const roll = rollPool(pool);
125
- if (this.scene.isHumanControlled(this.actor)) {
126
- this.logger.meta(`Spellwork: ${formatRoll(roll)} (2+ to unweave)`);
127
- }
128
- return roll.hits >= 2;
129
- }
130
- /**
131
- * A failed unweaving isn't quiet OR free: the ward's backlash is drain
132
- * (BACKLASH_DRAIN, resisted with Willpower like any drain), landing on
133
- * the real stun track with overflow into physical. Enough of it still
134
- * knocks out or kills -- but through dice and the condition monitors,
135
- * the same death any spell drain earns, not by fiat.
136
- */
137
- async onForceFailure() {
138
- const actor = this.actor;
139
- // Centering (metamagic, p.325): +grade dice on every drain resist --
140
- // the chant that keeps the backlash outside the skull.
141
- const resist = rollPool(actor.willpower + (actor.hasMetamagic('centering') ? actor.initiationGrade : 0));
142
- const taken = Math.max(0, CastCommand.BACKLASH_DRAIN - resist.hits);
143
- const lines = [
144
- ` The barrier's backlash arcs up your own weave:`,
145
- ];
146
- // Roll anatomy to the ticker for the player; an NPC keeps it in the
147
- // returned lines that feed its own AI history (readability pass).
148
- const backlashRoll = `Backlash (${CastCommand.BACKLASH_DRAIN}): resist ${formatRoll(resist)}`;
149
- if (this.scene.isHumanControlled(actor))
150
- this.logger.meta(backlashRoll);
151
- else
152
- lines.push(` ${backlashRoll}`);
153
- if (taken === 0) {
154
- lines.push(` You ground it out through clenched teeth -- shaken, but standing.`);
155
- return lines;
156
- }
157
- const { stun, overflow } = actor.takeStun(taken);
158
- this.logger.write(`${actor.name} took ${taken} ward backlash (stun ${actor.stunTaken}/${actor.maxStunBoxes}, overflow ${overflow}).`);
159
- lines.push(` It burns through you -- ${stun} stun: ${actor.stunSummary()}`);
160
- if (overflow > 0) {
161
- lines.push(` More than your mind can hold -- ${overflow} box${overflow === 1 ? '' : 'es'} spill into your body: ${actor.conditionSummary()}`);
162
- }
163
- // Backlash can still finish you -- through the tracks, same flows as
164
- // spell drain (cast.ts drainCollapse) and the stim crash (use.ts).
165
- if (actor.isIncapacitated()) {
166
- const human = this.scene.isHumanControlled(actor);
167
- const dead = actor.isDown();
168
- if (human) {
169
- if (dead) {
170
- // A runner past the track goes through the one door every
171
- // runner death takes (CombatExchange.resolveDeath): down and
172
- // bleeding short of Body overflow (p.170), and at death's door
173
- // -- DocWagon, Not Dead Yet, the banner -- past it. This called
174
- // endGame straight off a full track, and only for the primary.
175
- const exchange = new CombatExchange(this.scene, this.logger, actor);
176
- lines.push(...await exchange.resolveDeath(actor, actor, {
177
- fallLine: `The weave snaps back through every nerve at once. The last thing you feel is the barrier holding.`,
178
- gameOverLine: `💀 GAME OVER: the ward's backlash burned ${actor.name} out from the inside.`,
179
- cause: `a ward's backlash`,
180
- }));
181
- }
182
- else {
183
- // A stun KO defers to the room (RAW p.379/p.207, see
184
- // Scene.beginPlayerKnockout): a ward has no legs -- alone, the
185
- // player simply comes to at its foot; with hostiles about, the
186
- // street still gets its say.
187
- this.scene.addWorldEvent(`${actor.name} was knocked cold by a ward's backlash in ${actor.currentLocation.name}.`);
188
- lines.push('', `The world grays out -- the ward stands over you, humming, as everything goes dark.`);
189
- this.scene.beginPlayerKnockout([], actor);
190
- }
191
- }
192
- else {
193
- this.scene.addWorldEvent(dead
194
- ? `${actor.name} was killed by a ward's backlash in ${actor.currentLocation.name}.`
195
- : `${actor.name} was knocked cold by a ward's backlash in ${actor.currentLocation.name}.`);
196
- if (dead)
197
- this.scene.removeActor(actor.name);
198
- lines.push('', `${actor.name} drops at the barrier's foot, ${dead ? 'dead before they land' : 'out cold'}.`);
199
- }
200
- }
201
- return lines;
202
- }
203
107
  async execute(args = []) {
204
108
  // Incantations carry -- casting ends a sneak (see sneak.ts).
205
109
  this.actor.sneaking = false;
206
110
  // "cast increase reflexes ..." -- the natural phrasing for the spell
207
111
  // whose key is 'reflexes'; strip the qualifier before resolution or
208
- // the whole phrase falls through to the ward-bypass path.
112
+ // the whole phrase reads as no spell at all.
209
113
  let words = args ?? [];
210
114
  if (words[0]?.toLowerCase() === 'increase' && words[1]?.toLowerCase().startsWith('reflex')) {
211
115
  words = words.slice(1);
@@ -228,18 +132,17 @@ export class CastCommand extends BypassCommand {
228
132
  if (!this.actor.isCaster()) {
229
133
  return `Casting takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
230
134
  }
231
- // "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05): it used to
232
- // unweave a ward on that exit, which is DISPEL's job and now only
233
- // dispel's. A direction gets pointed at both verbs it might mean;
234
- // anything else is not a spell this caster knows.
135
+ // "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05), and a ward is
136
+ // not a door any more (2026-10-06): a direction gets the barrier
137
+ // spell it might mean; anything else is not a spell this caster knows.
235
138
  const first = words[0]?.toLowerCase();
236
139
  if (!first) {
237
140
  return `Cast what? "spells" lists what you know -- "cast <spell> [force] ...".`;
238
141
  }
239
142
  if (this.isValidDirection(first)) {
240
- return `To unweave a ward that way, "dispel ${first}". To wall the way off, "cast barrier ${first} [cells]".`;
143
+ return `To wall the way off, "cast barrier ${first} [cells]".`;
241
144
  }
242
- return `"${words.join(' ')}" isn't a spell you know. "spells" lists them; a ward is "dispel <it>".`;
145
+ return `"${words.join(' ')}" isn't a spell you know. "spells" lists them.`;
243
146
  }
244
147
  /**
245
148
  * ONE REFUSAL FOR THE ASPECT, wherever it is reached from -- the
@@ -254,8 +157,7 @@ export class CastCommand extends BypassCommand {
254
157
  /**
255
158
  * Lenient spell-name matching: the exact key, or a unique fragment of at
256
159
  * least 3 characters ("bolt"/"mana" -> manabolt). Anything that doesn't
257
- * resolve falls through to the ward-bypass path, which is what a
258
- * direction or barrier name ("cast north", "cast maglock") needs.
160
+ * resolve is answered as not a spell this caster knows.
259
161
  */
260
162
  resolveSpellName(arg) {
261
163
  const wanted = arg?.toLowerCase();
@@ -291,7 +193,7 @@ export class CastCommand extends BypassCommand {
291
193
  }
292
194
  // NOT A SHOPPING PROBLEM (EL9pWnQe3w5PieogT, found reading this
293
195
  // item's own bench). The aspect refusal above only guards the
294
- // ward-bypass path -- the one a spell NAME never reaches -- so a
196
+ // non-spell path -- the one a spell NAME never reaches -- so a
295
197
  // named spell fell through to the line below and told a Conjurer to
296
198
  // go and buy a formula or a grimoire. p.69 says neither will ever
297
199
  // work for them: the formula stands in for the SKILL, and the skill
@@ -381,31 +283,6 @@ export class CastCommand extends BypassCommand {
381
283
  }
382
284
  return { target };
383
285
  }
384
- /**
385
- * Does this string name a WARD in the room? Checked only AFTER actor
386
- * matching fails, so a person's name always wins. Matches the exit it
387
- * holds, its own name, a noun from its description, or -- with
388
- * exactly one ward here -- a generic barrier word.
389
- */
390
- findSpellBarrier(rawInput) {
391
- const raw = rawInput.trim().toLowerCase();
392
- const room = this.actor.currentLocation;
393
- const targets = (room?.openableDevices() ?? []).filter(d => d.kind === 'ward');
394
- if (targets.length === 0)
395
- return undefined;
396
- if (this.isValidDirection(raw))
397
- return targets.find(d => d.opensExit === raw);
398
- if (raw.length < 3)
399
- return undefined;
400
- const named = targets.filter(d => d.name.toLowerCase().includes(raw)
401
- || d.description.toLowerCase().includes(raw));
402
- if (named.length === 1)
403
- return named[0];
404
- if (targets.length === 1 && /^(the )?(lock|door|ward|barrier|seal|gate|hatch|glyph|field|sigil|way)$/.test(raw)) {
405
- return targets[0];
406
- }
407
- return undefined;
408
- }
409
286
  /** How the caster's pool reads in narration: focus-assisted, raw talent, or book-learned. */
410
287
  castingLabel() {
411
288
  const focus = this.actor.getBestFocus();
@@ -604,17 +481,6 @@ export class CastCommand extends BypassCommand {
604
481
  const pickedName = fuzzyPickName(targetName, candidates.map(a => a.name));
605
482
  const target = pickedName ? candidates.find(a => a.name === pickedName) : undefined;
606
483
  if (!target) {
607
- // A WARDED BARRIER is a legal target (a real session tried "cast
608
- // manabolt at hatch", "at lock", "at down" and bounced on all
609
- // three): when the words name no person but DO name a spell-keyed
610
- // barrier here -- by direction, title, or a noun from its own
611
- // description -- the cast routes to the ward test. The specific
612
- // spell doesn't matter: "show us your spark" wants spellwork, and
613
- // the bypass rolls the full casting pool.
614
- const barrier = this.findSpellBarrier(targetName);
615
- if (barrier) {
616
- return super.execute([barrier.opensExit ?? barrier.name]);
617
- }
618
484
  // Visible on another plane beats "no one" -- an astral caster CAN
619
485
  // see the meat target they're aiming at; name the actual rule
620
486
  // (see planes.crossPlaneRefusal).
@@ -1273,8 +1139,8 @@ export class CastCommand extends BypassCommand {
1273
1139
  * of each per caster; casting again replaces the standing one, with a
1274
1140
  * fresh roll and fresh drain. -2 sustaining like every held weave.
1275
1141
  *
1276
- * Bare "cast north" no longer reaches a ward from here -- that is
1277
- * "dispel north" (user ruling 2026-10-05).
1142
+ * Bare "cast north" reaches no ward: a ward is a room's astral barrier
1143
+ * now (utilities/wards.ts), not a door (user rulings 2026-10-05/06).
1278
1144
  */
1279
1145
  async castBarrier(spellKey, force, overcast, rest,
1280
1146
  /** Set when no Force was named: the talent a sized cast may reach. */
@@ -0,0 +1,76 @@
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 { overwatchFromDefense } from '../utilities/matrix-intrusion.js';
7
+ import { accrueOverwatch } from '../utilities/overwatch.js';
8
+ import { leaveMatrix } from '../utilities/planes.js';
9
+ import { hostLabel } from '../utilities/grid-names.js';
10
+ import { pickHostFile } from '../utilities/file-guard.js';
11
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
12
+ /**
13
+ * CRACK FILE (SR5 p.238, RAG-checked 2026-10-06): "removes the protection
14
+ * from a file, making it readable". Complex Action, Marks Required 1,
15
+ * Hacking + Logic [Attack] v. Protection Rating x 2. An Attack action
16
+ * (p.244), so GOD bills the defence hits (p.231-232). No penalty on a
17
+ * miss beyond that bill, and -- not being on p.242's trigger list -- it
18
+ * never sets off a data bomb.
19
+ *
20
+ * The mark is the host's, the stand-in edit-file.ts already uses for a
21
+ * mark on a file inside it.
22
+ */
23
+ export class CrackCommand extends Command {
24
+ static verb = 'crack';
25
+ static description = 'Crack File (SR5 p.238, Complex Action): "crack <file>" -- Hacking + Logic [Attack] v. the file\'s Protection x 2, from inside the host with a mark on it. An Attack action: GOD counts the defence hits. A cracked file can be read, copied and erased.';
26
+ async execute(args = []) {
27
+ const actor = this.actor;
28
+ const found = pickHostFile(actor, args.join(' ').trim(), 'crack');
29
+ if (typeof found === 'string')
30
+ return found;
31
+ const { host, file } = found;
32
+ const room = host.over;
33
+ if (!file.protection) {
34
+ return `${file.name} carries no protection -- nothing to crack.${file.dataBomb && file.bombSeenBy.has(actor.name) ? hint(` (The bomb on it is another matter: "disarm ${file.name}".)`) : ''}`;
35
+ }
36
+ if ((room.hostMarksBy.get(actor.name) ?? 0) < 1) {
37
+ return `Crack File needs a MARK (p.238) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
38
+ }
39
+ const burned = actor.burnedAttributeRefusal('attack');
40
+ if (burned)
41
+ return burned;
42
+ const bill = billAction(this.scene, actor, 'complex', 'Crack File');
43
+ if (bill)
44
+ return bill;
45
+ const hacking = actor.skillRating('hacking');
46
+ const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, actor.currentGrid);
47
+ const pool = Math.max(1, actor.logic + (hacking > 0 ? hacking : -1) + actor.bonus('hacking')
48
+ + actor.matrixActionPenalty + grid.total + actor.woundModifier - actor.sustainingPenalty);
49
+ const attempt = rollPool(pool, actor.matrixAttribute('attack'), { gremlins: actor.deckGremlins });
50
+ const resist = rollPool(file.protection * 2);
51
+ if (showsMechanics(this.scene, actor)) {
52
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Crack File -- Hacking + Logic [Attack]: ${formatRoll(attempt)}`);
53
+ this.logger.meta(` v. ${file.name}'s Protection ${file.protection} x 2: ${formatRoll(resist)}`);
54
+ }
55
+ actor.performAction('cracks a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
56
+ const god = this.godBill(overwatchFromDefense(resist.hits), 'Crack File (attack)');
57
+ const tail = god.length > 0 ? `\n${god.join('\n')}` : '';
58
+ if (attempt.hits <= resist.hits) {
59
+ this.logger.write(`${actor.name} failed to crack "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
60
+ return `The encryption holds -- ${file.name} stays a sealed block of noise.${hint(` (Try again; each attempt is a Complex Action.)`)}${tail}`;
61
+ }
62
+ file.protection = 0;
63
+ this.logger.write(`${actor.name} cracked "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
64
+ this.scene.updateStatus();
65
+ return `{light-blue-fg}The protection on ${file.name} unravels -- the file reads clean now.{/light-blue-fg}${tail}`;
66
+ }
67
+ /** GOD's bill, and the forced dump when it converges (erase-mark.ts). */
68
+ godBill(defenseHits, why) {
69
+ const bill = accrueOverwatch(this.scene, this.actor, defenseHits, why);
70
+ if (bill.converged && this.actor.plane === 'matrix') {
71
+ bill.lines.push(...leaveMatrix(this.scene, this.actor, { forced: true, reason: `GOD force-reboots your persona --` }));
72
+ }
73
+ return bill.lines;
74
+ }
75
+ }
76
+ //# sourceMappingURL=crack.js.map
@@ -4,7 +4,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  import { hostsInReach, hostOver, gridVicinity, roomsInReach } from '../utilities/grid-reach.js';
6
6
  import { hostLabel, matchesCameraName } from '../utilities/grid-names.js';
7
- import { isWatched, cameraMasterHost, stillWatching } from '../utilities/surveillance.js';
7
+ import { isWatched, lensMaster, stillWatching } from '../utilities/surveillance.js';
8
8
  import { deviceDefensePool, wanDefensePool } from '../utilities/matrix-intrusion.js';
9
9
  import { accrueOverwatch } from '../utilities/overwatch.js';
10
10
  import { leaveMatrix, leaveDrone } from '../utilities/planes.js';
@@ -17,6 +17,7 @@ import { MAX_MARKS } from '../utilities/marks.js';
17
17
  import { deviceMaster, wanDevices } from '../utilities/wan.js';
18
18
  import { sameHostSide } from '../models/player.js';
19
19
  import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
20
+ import { ownerInformed } from '../utilities/owner-guard.js';
20
21
  /** The marks Control Device wants for a Simple device action (p.238). */
21
22
  export const MARKS_TO_CONTROL = 2;
22
23
  export class DisableCommand extends Command {
@@ -68,8 +69,37 @@ export class DisableCommand extends Command {
68
69
  // 1. A CAMERA of the room the persona is over / inside -- by its own
69
70
  // name ("north camera"), or "camera"/"cameras" for the first lens
70
71
  // still recording. One lens per action (p.238-239).
71
- const camRoom = actor.insideHost ?? (actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation);
72
- const namedLens = isWatched(camRoom) ? camRoom.cameraNamed(named) : undefined;
72
+ // INSIDE A HOST, ITS CAMERA WAN IS EVERY LENS SLAVED TO IT, wherever
73
+ // the lens hangs (surveillance.ts lensMaster; user 2026-10-06: a
74
+ // camera on the strip outside a shop is the shop's) -- and not a lens
75
+ // on its own floor that an author slaved to some other host.
76
+ const inside = actor.insideHost;
77
+ if (inside) {
78
+ const slaved = [];
79
+ for (const r of [inside, ...Object.values(rooms).filter(r => r !== inside)]) {
80
+ if (!isWatched(r))
81
+ continue;
82
+ for (const c of r.ensureCameras())
83
+ if (lensMaster(rooms, r, c) === inside)
84
+ slaved.push({ room: r, camera: c });
85
+ }
86
+ const namedHit = slaved.find(s => s.room.cameraNamed(named) === s.camera);
87
+ if (namedHit || matchesCameraName(named)) {
88
+ if (slaved.length === 0)
89
+ return `No cameras answer to ${hostLabel(inside)} -- nothing to loop.`;
90
+ const pick = namedHit ?? slaved.find(s => s.camera.working) ?? slaved.find(s => !s.camera.destroyed && !s.camera.bricked);
91
+ if (!pick)
92
+ return `Every lens slaved to ${hostLabel(inside)} is already dead -- smashed or bricked, no icon left to order.`;
93
+ if (pick.camera.destroyed)
94
+ return `The ${pick.camera.name} is smashed -- glass and a dangling cable. No icon left to reach.`;
95
+ // The WAN order loops the picked lens's room, one room per order,
96
+ // so the report below can say which room went blind.
97
+ const wan = namedHit ? undefined : slaved.filter(s => s.room === pick.room && s.camera.working).map(s => s.camera);
98
+ return { kind: 'camera', room: pick.room, camera: pick.camera, master: inside, ...(wan && wan.length > 1 ? { wan } : {}) };
99
+ }
100
+ }
101
+ const camRoom = inside ?? (actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation);
102
+ const namedLens = !inside && isWatched(camRoom) ? camRoom.cameraNamed(named) : undefined;
73
103
  if (namedLens || matchesCameraName(named)) {
74
104
  if (!isWatched(camRoom))
75
105
  return `No cameras on ${camRoom.name} -- nothing to loop.`;
@@ -80,8 +110,7 @@ export class DisableCommand extends Command {
80
110
  // A lens shot off the wall is not on the grid at all.
81
111
  if (camera.destroyed)
82
112
  return `The ${camera.name} is smashed -- glass and a dangling cable. No icon left to reach.`;
83
- const wan = !namedLens && actor.insideHost === camRoom ? cams.filter(c => c.working) : undefined;
84
- return { kind: 'camera', room: camRoom, camera, master: cameraMasterHost(rooms, camRoom), ...(wan && wan.length > 1 ? { wan } : {}) };
113
+ return { kind: 'camera', room: camRoom, camera, master: lensMaster(rooms, camRoom, camera) };
85
114
  }
86
115
  // 2. A DEVICE ICON: inside the host, its WAN; from the grid, a slaved
87
116
  // device you hold a host mark for, or a loose wireless one where
@@ -92,7 +121,6 @@ export class DisableCommand extends Command {
92
121
  const matching = (entries, inHost = false) => entries.filter(({ device: d }) => d.rating > 0 && d.hasIconFor(actor.name, inHost) && (d.name.toLowerCase().includes(raw)
93
122
  || (d.opensExit ?? '').toLowerCase() === raw
94
123
  || d.description.toLowerCase().includes(raw)));
95
- const inside = actor.insideHost;
96
124
  if (inside) {
97
125
  const matches = matching(wanDevices(rooms, inside), true);
98
126
  if (matches.length === 1)
@@ -252,6 +280,14 @@ export class DisableCommand extends Command {
252
280
  host.host?.spotted.add(actor.name);
253
281
  }
254
282
  }
283
+ // THE PLACE'S OWNER is told too (owner-guard.ts): a blown Control
284
+ // Device hands them the mark; a landed Data Spike raises the alarm.
285
+ if (t.kind === 'device' || t.kind === 'camera') {
286
+ const what = t.kind === 'device' ? t.device.name : `The ${t.room.name} cameras`;
287
+ const told = ownerInformed(this.scene, t.room, actor, made ? 'device-sleaze' : 'attack-landed', what);
288
+ if (told)
289
+ this.logger.logWithColor(told, 'yellow');
290
+ }
255
291
  if ((t.kind === 'gun' || t.kind === 'implant') && made) {
256
292
  actor.panMarksBy.set(t.owner.name, Math.min(MAX_MARKS, (actor.panMarksBy.get(t.owner.name) ?? 0) + 1));
257
293
  t.owner.performAction('notices a Matrix probe', 'their link flashing an intrusion warning');