@maka/maka-cli 5.181.0 → 5.183.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 (28) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +20 -11
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +331 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +14 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +17 -10
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +6 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +13 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +84 -30
  10. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +10 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/game.js +51 -40
  12. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +40 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +4 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +6 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +6 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +6 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +44 -17
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +91 -23
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +30 -4
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +107 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +84 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +84 -51
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +5 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js +50 -21
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +6 -2
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +4 -0
  28. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.181.0",
3
+ "version": "5.183.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.",
@@ -10,6 +10,7 @@ import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_
10
10
  import { distanceMeters } from '../utilities/room-grid.js';
11
11
  import { resolveBarrierStrike } from '../utilities/barrier-combat.js';
12
12
  import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
13
+ import { arenaOf, inArena } from '../utilities/combat-turn.js';
13
14
  import { spendMovementMeters } from '../utilities/movement-cost.js';
14
15
  /**
15
16
  * Shadowrun-style dice-pool combat, one full exchange per command:
@@ -217,6 +218,9 @@ export class AttackCommand extends Command {
217
218
  // holstered dry/jammed piece is not what's being attacked with, so it
218
219
  // must not block a bare-fisted attack the player never asked to fail.
219
220
  const weaponItem = this.actor.equipment.rightHand?.weapon ?? this.actor.equipment.leftHand?.weapon;
221
+ if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.bricked) {
222
+ return `The ${weaponItem.name} is BRICKED -- its electronics burned out under a Data Spike (p.228). Bench work while you rest brings it back; for now, go in swinging.`;
223
+ }
220
224
  if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.jammed) {
221
225
  return `The ${weaponItem.name}'s smartlink is LOCKED -- someone cracked your PAN. "reboot" to clear it, or go in swinging.`;
222
226
  }
@@ -224,11 +228,12 @@ export class AttackCommand extends Command {
224
228
  return `Click -- the ${weaponItem.name} is dry. "reload" (needs ammunition), or go in swinging with something else.`;
225
229
  }
226
230
  // THE COMBAT TURN (utilities/combat-turn.ts): on the physical planes
227
- // a fight is a turn/pass loop and this attack is ONE action in the
228
- // actor's own Action Phase. The Matrix and the astral keep the older
229
- // exchange below -- the Matrix has its own initiative structure
230
- // (utilities/ic.ts) and astral combat has not been swept yet.
231
- if (this.actor.plane === 'meat' || this.actor.plane === 'drone') {
231
+ // and the Matrix a fight is a turn/pass loop and this attack is ONE
232
+ // action in the actor's own Action Phase. The Matrix joined it on
233
+ // 2026-09-13 (utilities/host-combat.ts): a Data Spike used to roll a
234
+ // fresh two-actor initiative per command and print "FREEZES" every
235
+ // time. Only the astral still rides the older exchange below.
236
+ if (this.actor.plane === 'meat' || this.actor.plane === 'drone' || this.actor.plane === 'matrix') {
232
237
  return await this.executeInEncounter(target, room, weaponItem ?? undefined, ambush);
233
238
  }
234
239
  const report = [];
@@ -340,8 +345,9 @@ export class AttackCommand extends Command {
340
345
  const actor = this.actor;
341
346
  const human = this.scene.isHumanControlled(actor);
342
347
  let enc = this.scene.encounterFor?.(actor);
348
+ const arena = arenaOf(actor);
343
349
  if (!enc) {
344
- enc = await this.scene.startEncounter(room, { aggressor: actor, target, ambush });
350
+ enc = await this.scene.startEncounter(arena, { aggressor: actor, target, ambush });
345
351
  if (enc.ended) {
346
352
  return human ? `` : `The fight was over before ${actor.name} got to act.`;
347
353
  }
@@ -356,7 +362,7 @@ export class AttackCommand extends Command {
356
362
  ? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${this.actionVerb() === 'attacks' ? 'attack' : 'move'} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
357
363
  : `${actor.name} squares up -- initiative rolled; ${who} first.`;
358
364
  }
359
- if (target.isIncapacitated() || target.currentLocation !== room) {
365
+ if (target.isIncapacitated() || !inArena(arena, target)) {
360
366
  return human ? `${target.name} is no longer standing in front of you.` : `${target.name} is no longer a target.`;
361
367
  }
362
368
  }
@@ -372,7 +378,10 @@ export class AttackCommand extends Command {
372
378
  // shoot or swing the blade instead? READY WEAPON (p.165) is its own
373
379
  // Simple Action -- "draw" first (2jbprbSYqF7EPXrkR).
374
380
  const activeWeapon = this.usesEquippedWeapon() && actor.weaponDrawn ? weaponItem : undefined;
375
- const isMelee = !activeWeapon || !activeWeapon.isFirearm();
381
+ // A DATA SPIKE (p.239) is neither a swing nor a shot: no ground to
382
+ // close, no recoil, a Complex Action. The grid sees no distance.
383
+ const onGrid = actor.plane === 'matrix';
384
+ const isMelee = !onGrid && (!activeWeapon || !activeWeapon.isFirearm());
376
385
  const report = [];
377
386
  // MELEE REACH: ground costs (p.161-162).
378
387
  if (isMelee) {
@@ -428,8 +437,8 @@ export class AttackCommand extends Command {
428
437
  }
429
438
  }
430
439
  // THE ACTION.
431
- const label = isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
432
- const bill = billAction(this.scene, actor, isMelee ? 'complex' : 'simple', label, { attack: true });
440
+ const label = onGrid ? 'Data Spike' : isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
441
+ const bill = billAction(this.scene, actor, onGrid || isMelee ? 'complex' : 'simple', label, { attack: true });
433
442
  if (bill)
434
443
  return bill;
435
444
  const exchange = new CombatExchange(this.scene, this.logger, actor);
@@ -442,7 +451,7 @@ export class AttackCommand extends Command {
442
451
  target.combatOpponent = target.combatOpponent ?? actor;
443
452
  target.lastExchangeAt = now;
444
453
  actor.surrendered = false;
445
- if (!isMelee) {
454
+ if (!isMelee && !onGrid) {
446
455
  actor.firedThisPhase = true;
447
456
  actor.recoilRoundsFired += 1;
448
457
  }
@@ -0,0 +1,331 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { rollPool, formatRoll } from '../utilities/dice.js';
4
+ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
+ import { hostsInReach, hostOver, gridVicinity, roomsInReach } from '../utilities/grid-reach.js';
6
+ import { hostLabel, matchesCameraName } from '../utilities/grid-names.js';
7
+ import { isWatched, cameraMasterHost } from '../utilities/surveillance.js';
8
+ import { deviceDefensePool, wanDefensePool, hostDefensePool } from '../utilities/matrix-intrusion.js';
9
+ import { accrueOverwatch } from '../utilities/overwatch.js';
10
+ import { leaveMatrix } from '../utilities/planes.js';
11
+ import { applyDeviceOpened } from '../utilities/devices.js';
12
+ import { gridActionModifier, gridNote } from '../utilities/grids.js';
13
+ import { billAction } from '../utilities/action-cost.js';
14
+ import { MAX_MARKS } from '../utilities/marks.js';
15
+ import { sameHostSide } from '../models/player.js';
16
+ /** The marks Control Device wants for a Simple device action (p.238). */
17
+ export const MARKS_TO_CONTROL = 2;
18
+ export class DisableCommand extends Command {
19
+ static verb = 'disable';
20
+ static description = 'Control Device (SR5 p.238): with 2 marks on a device, order it -- a maglock unlocks, a camera cluster loops, a smartgun\'s clip drops. Electronic Warfare + Intuition [Sleaze] v. Device Rating + Firewall. "disable loud <icon>" is a Data Spike instead (see "brick").';
21
+ /** The Data Spike route: `brick` always, `disable loud` by the word. */
22
+ way(args) {
23
+ const rest = [...args];
24
+ if (rest[0]?.toLowerCase() === 'loud' || rest[0]?.toLowerCase() === 'brick') {
25
+ rest.shift();
26
+ return { way: 'spike', rest };
27
+ }
28
+ if (rest[0]?.toLowerCase() === 'quiet')
29
+ rest.shift();
30
+ return { way: 'control', rest };
31
+ }
32
+ async execute(args = []) {
33
+ const actor = this.actor;
34
+ const { way, rest } = this.way(args);
35
+ const named = rest.join(' ').trim();
36
+ if (!named) {
37
+ return way === 'spike'
38
+ ? `Brick what? Name the icon: "brick <device>", "brick cameras", "brick <person>" for their gun.`
39
+ : `Disable what? Name the icon: "disable <device>", "disable cameras", "disable <person>" for their gun.${hint(` (Control Device wants ${MARKS_TO_CONTROL} marks on it -- "hack <it>" first.)`)}`;
40
+ }
41
+ // REACH: riding the Matrix, a living persona, or AR with a working
42
+ // deck in hand -- the same gate a PAN intrusion uses (hack.ts).
43
+ if (actor.plane === 'astral')
44
+ return `The Matrix doesn't answer to spirits.`;
45
+ if (actor.plane !== 'matrix' && !actor.isTechnomancer() && !actor.getCyberdeck()) {
46
+ return `You'd need to be jacked in, or have a working cyberdeck in hand, to reach a device's icon.`;
47
+ }
48
+ const target = this.resolve(named);
49
+ if (typeof target === 'string')
50
+ return target;
51
+ const bill = billAction(this.scene, actor, 'complex', way === 'spike' ? 'Data Spike' : 'Control Device', { attack: way === 'spike' });
52
+ if (bill)
53
+ return bill;
54
+ return way === 'spike' ? this.dataSpike(target) : this.controlDevice(target);
55
+ }
56
+ // ------------------------------------------------------------ targets --
57
+ resolve(named) {
58
+ const actor = this.actor;
59
+ const raw = named.toLowerCase();
60
+ const rooms = this.rooms ?? this.scene.getRooms();
61
+ // 1. THE CAMERA CLUSTER of the room the persona is over / inside.
62
+ if (matchesCameraName(named)) {
63
+ const room = actor.insideHost ?? (actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation);
64
+ if (!isWatched(room))
65
+ return `No cameras on ${room.name} -- nothing to loop.`;
66
+ return { kind: 'camera', room, master: cameraMasterHost(rooms, room) };
67
+ }
68
+ // 2. A DEVICE ICON: inside the host, its WAN; from the grid, a slaved
69
+ // device you hold a host mark for, or a loose wireless one where
70
+ // the body stands. Mirrors hack.ts tryDeviceHack.
71
+ const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.name.toLowerCase().includes(raw)
72
+ || (d.opensExit ?? '').toLowerCase() === raw
73
+ || d.description.toLowerCase().includes(raw));
74
+ const inside = actor.insideHost;
75
+ if (inside) {
76
+ const matches = matching(inside);
77
+ if (matches.length === 1)
78
+ return { kind: 'device', device: matches[0], room: inside, host: inside, viaWan: false };
79
+ if (matches.length > 1)
80
+ return `More than one thing in here answers to "${named}": ${matches.map(d => d.name).join(', ')}.`;
81
+ }
82
+ else {
83
+ const found = hostsInReach(rooms, actor).map(r => ({ room: r, matches: matching(r) })).filter(x => x.matches.length === 1);
84
+ if (found.length === 1) {
85
+ const host = found[0].room;
86
+ const device = found[0].matches[0];
87
+ if ((host.hostMarksBy.get(actor.name) ?? 0) < 1) {
88
+ return `${device.name} hangs off ${hostLabel(host)}'s WAN -- slaved, and a host's devices answer nobody who holds no mark on the host itself (p.233).${hint(` ("hack ${hostLabel(host)}" for a mark first.)`)}`;
89
+ }
90
+ return { kind: 'device', device, room: host, host, viaWan: true };
91
+ }
92
+ const here = actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation;
93
+ const loose = hostOver(here) ? [] : matching(here).filter(d => d.wireless);
94
+ if (loose.length === 1)
95
+ return { kind: 'device', device: loose[0], room: here, viaWan: false };
96
+ }
97
+ // 3. A PERSON'S GUN, broken out of their PAN (p.219). Same reach as
98
+ // a PAN intrusion: bodies on this side of the host wall.
99
+ const reach = (actor.plane === 'matrix' && !actor.insideHost)
100
+ ? roomsInReach(rooms, actor)
101
+ : [{ room: actor.currentLocation, noise: 0 }];
102
+ const bodies = reach.flatMap(({ room: r }) => this.scene.getActorsInRoom(r)
103
+ .filter(a => a !== actor && a.plane === 'meat' && !a.isIncapacitated() && sameHostSide(actor, a)));
104
+ const picked = fuzzyPickName(named, bodies.map(a => a.name));
105
+ const owner = picked ? bodies.find(a => a.name === picked) : undefined;
106
+ if (owner) {
107
+ const gun = owner.equipment.rightHand?.weapon ?? owner.equipment.leftHand?.weapon;
108
+ if (!gun || !gun.isFirearm())
109
+ return `${owner.name} has no firearm on the grid to work -- nothing wireless in their hands.`;
110
+ return { kind: 'gun', owner, gun };
111
+ }
112
+ return `Nothing on the grid answers to "${named}".${hint(` ("look" lists the icons in reach.)`)}`;
113
+ }
114
+ marksOn(t) {
115
+ const me = this.actor.name;
116
+ if (t.kind === 'device')
117
+ return t.device.marksBy.get(me) ?? 0;
118
+ if (t.kind === 'camera')
119
+ return t.master?.hostMarksBy.get(me) ?? 0;
120
+ return t.owner.panMarksBy.get(me) ?? 0;
121
+ }
122
+ nameOf(t) {
123
+ return t.kind === 'device' ? t.device.name : t.kind === 'camera' ? `the camera cluster on ${t.room.name}` : `${t.owner.name}'s ${t.gun.name}`;
124
+ }
125
+ /** What defends: DR + Firewall for an unattended device (the master's
126
+ * where higher, seen from the grid), the host for its cameras, the
127
+ * owner's Intuition + Firewall for a gun on their PAN. */
128
+ defence(t) {
129
+ if (t.kind === 'device') {
130
+ return t.viaWan && t.host?.host ? wanDefensePool(t.device, t.host.host) : deviceDefensePool(t.device);
131
+ }
132
+ if (t.kind === 'camera') {
133
+ return t.master?.host ? hostDefensePool(t.master.host) : { label: 'Device Rating + Firewall', pool: 4 };
134
+ }
135
+ return { label: 'Intuition + Firewall', pool: t.owner.getPanDefensePool('sleaze') };
136
+ }
137
+ godBill(defenseHits, why) {
138
+ const bill = accrueOverwatch(this.scene, this.actor, defenseHits, why);
139
+ if (bill.converged && this.actor.plane === 'matrix') {
140
+ bill.lines.push(...leaveMatrix(this.scene, this.actor, { forced: true, reason: `GOD force-reboots your persona --` }));
141
+ }
142
+ return bill.lines;
143
+ }
144
+ /** The owner of the thing learns something went wrong. */
145
+ wakeOwner(t, made) {
146
+ const actor = this.actor;
147
+ actor.sneaking = false;
148
+ const host = t.kind === 'device' ? t.host : t.kind === 'camera' ? t.master : undefined;
149
+ if (host) {
150
+ host.hostAlert = true;
151
+ if (made)
152
+ host.hostMarksOn.set(actor.name, Math.min(MAX_MARKS, (host.hostMarksOn.get(actor.name) ?? 0) + 1));
153
+ }
154
+ if (t.kind === 'gun' && made) {
155
+ actor.panMarksBy.set(t.owner.name, Math.min(MAX_MARKS, (actor.panMarksBy.get(t.owner.name) ?? 0) + 1));
156
+ t.owner.performAction('notices a Matrix probe', 'their link flashing an intrusion warning');
157
+ }
158
+ this.scene.updateStatus();
159
+ }
160
+ // ------------------------------------------------------ Control Device --
161
+ controlDevice(t) {
162
+ const actor = this.actor;
163
+ const name = this.nameOf(t);
164
+ const marks = this.marksOn(t);
165
+ if (marks < MARKS_TO_CONTROL) {
166
+ const how = t.kind === 'device' ? `"hack ${t.device.name}"` : t.kind === 'camera' ? `"hack ${t.master ? hostLabel(t.master) : 'the host'}"` : `"hack ${t.owner.name}"`;
167
+ return `${name} takes orders from ${MARKS_TO_CONTROL} marks and you hold ${marks} (Control Device, p.238: a Simple device action wants two).${hint(` (${how} places them; or "brick" it -- no marks, but loud, and a bricked lock stays locked.)`)}`;
168
+ }
169
+ const burned = actor.burnedAttributeRefusal('sleaze');
170
+ if (burned)
171
+ return burned;
172
+ // ELECTRONIC WARFARE + INTUITION [SLEAZE] v. Intuition + Firewall
173
+ // (p.238), the no-test device action's test. Unskilled at -1 (p.130).
174
+ const ew = actor.skillRating('electronic-warfare');
175
+ const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, t.kind === 'device' ? t.host?.host?.grid : t.kind === 'camera' ? t.master?.host?.grid : undefined);
176
+ const pool = Math.max(1, actor.intuition + (ew > 0 ? ew : -1) + actor.bonus('hacking') + actor.bonus('matrix-sleaze')
177
+ + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty + grid.total);
178
+ const limit = actor.matrixAttribute('sleaze');
179
+ const roll = rollPool(pool, limit, { gremlins: actor.deckGremlins });
180
+ const defence = this.defence(t);
181
+ const defenceRoll = rollPool(defence.pool);
182
+ const net = roll.hits - defenceRoll.hits;
183
+ actor.performAction('takes control of a device', name);
184
+ if (this.scene.isHumanControlled(actor)) {
185
+ this.logger.meta(`Control Device (Electronic Warfare + Intuition [Sleaze])${gridNote(grid)}: ${formatRoll(roll)}`);
186
+ this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
187
+ }
188
+ const godLines = this.godBill(defenceRoll.hits, 'Control Device (sleaze)');
189
+ if (net <= 0) {
190
+ // A blown Sleaze is noticed (p.236): the owner marks you and the
191
+ // host it hangs off is awake.
192
+ this.wakeOwner(t, true);
193
+ const who = t.kind === 'gun' ? `${t.owner.name}'s firewall holds a MARK on your persona now` : `whoever owns it has your icon on file now`;
194
+ return [`${name} refuses the order and SAYS SO -- ${who}.`, ...godLines].join('\n');
195
+ }
196
+ const lines = [];
197
+ if (t.kind === 'device') {
198
+ // THE ORDER: it opens. Same aftermath as a key or a forced door
199
+ // (utilities/devices.ts) -- the exit unseals in the meat.
200
+ const opened = t.device.force(true);
201
+ lines.push(`${t.device.name} takes the order as its owner's: ${opened.message}`);
202
+ lines.push(...applyDeviceOpened(this.scene, actor, t.room, t.device, 'disable'));
203
+ }
204
+ else if (t.kind === 'camera') {
205
+ t.room.camerasLooped = true;
206
+ lines.push(`The cluster on ${t.room.name} takes the order: every lens loops on an empty hallway. Security sees NOTHING there now.${hint(` ("snoop ${t.room.name}" rides the real feed.)`)}`);
207
+ this.scene.addWorldEvent(`An unseen hand looped the cameras on ${t.room.name}.`);
208
+ }
209
+ else {
210
+ t.gun.jammed = true;
211
+ lines.push(`${t.owner.name}'s ${t.gun.name} takes the order: the smartlink seizes and the clip drops -- LOCKED until they reboot.`);
212
+ t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} locking up in their hand`);
213
+ this.scene.addWorldEvent(`An unseen hand seized ${t.owner.name}'s ${t.gun.name}.`);
214
+ }
215
+ this.scene.updateStatus();
216
+ return [...lines, ...godLines].join('\n');
217
+ }
218
+ // ----------------------------------------------------------- Data Spike --
219
+ dataSpike(t) {
220
+ const actor = this.actor;
221
+ const name = this.nameOf(t);
222
+ const burned = actor.burnedAttributeRefusal('attack');
223
+ if (burned)
224
+ return burned;
225
+ if (t.kind === 'device' && t.device.isBricked)
226
+ return `${t.device.name} is already bricked -- dead boards, nothing left to spike.`;
227
+ if (t.kind === 'camera' && t.room.camerasBricked)
228
+ return `The cluster on ${t.room.name} is already dead glass.`;
229
+ if (t.kind === 'gun' && t.gun.bricked)
230
+ return `${t.owner.name}'s ${t.gun.name} is already bricked.`;
231
+ // CYBERCOMBAT + LOGIC [ATTACK] v. Intuition + Firewall (p.239).
232
+ const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, t.kind === 'device' ? t.host?.host?.grid : t.kind === 'camera' ? t.master?.host?.grid : undefined);
233
+ const pool = Math.max(1, actor.getAttackPool() + actor.matrixActionPenalty + actor.bonus('matrix-attack') + grid.total);
234
+ const limit = actor.matrixAttribute('attack');
235
+ const roll = rollPool(pool, limit, { gremlins: actor.deckGremlins });
236
+ const defence = this.defence(t);
237
+ const defenceRoll = rollPool(defence.pool);
238
+ const net = roll.hits - defenceRoll.hits;
239
+ actor.performAction('throws a Data Spike', name);
240
+ if (this.scene.isHumanControlled(actor)) {
241
+ this.logger.meta(`Data Spike (Cybercombat + Logic [Attack ${limit}])${gridNote(grid)}: ${formatRoll(roll)}`);
242
+ this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
243
+ }
244
+ const godLines = this.godBill(defenceRoll.hits, 'Data Spike (attack)');
245
+ if (net <= 0) {
246
+ // A failed Attack action is never noticed (p.236), but the
247
+ // target's software rejects the code and sends it back: one
248
+ // unresistable box per net hit the defender got (Data Trails p.181).
249
+ const rebound = Math.max(0, -net);
250
+ const lines = [`${name} shrugs the spike off${rebound > 0 ? ' -- and sends it back' : ''}.`];
251
+ if (rebound > 0) {
252
+ if (actor.isTechnomancer()) {
253
+ actor.takeStun(rebound);
254
+ lines.push(` Rejected code burns back through your living persona -- ${rebound} stun: ${actor.stunSummary()}`);
255
+ }
256
+ else {
257
+ actor.takeMatrixDamage(rebound);
258
+ lines.push(` Rejected code burns back into your deck -- ${rebound} box${rebound === 1 ? '' : 'es'}, unresisted: ${actor.matrixSummary()}`);
259
+ if (actor.matrixCrashed)
260
+ lines.push(...leaveMatrix(this.scene, actor, { forced: true, reason: `Your own spike bricks the deck under you.` }));
261
+ }
262
+ }
263
+ lines.push(` Nobody upstairs noticed -- a spike that doesn't land leaves no trace.`);
264
+ return [...lines, ...godLines].join('\n');
265
+ }
266
+ // DV = Attack + net hits, +2 per mark held (p.239); resisted with
267
+ // Device Rating + Firewall (p.228).
268
+ const marks = this.marksOn(t);
269
+ const dv = limit + net + 2 * marks;
270
+ const soak = rollPool(this.defence(t).pool);
271
+ const dealt = Math.max(0, dv - soak.hits);
272
+ if (this.scene.isHumanControlled(actor)) {
273
+ 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}`);
274
+ }
275
+ // An Attack action is never subtle: the owner knows.
276
+ this.wakeOwner(t, false);
277
+ const lines = [];
278
+ if (dealt <= 0) {
279
+ lines.push(`${name} takes the spike and its firewall holds -- barely. It knows it was hit.`);
280
+ return [...lines, ...godLines].join('\n');
281
+ }
282
+ if (t.kind === 'device') {
283
+ t.device.takeMatrixDamage(dealt);
284
+ lines.push(`The spike lands on ${t.device.name} -- ${dealt} box${dealt === 1 ? '' : 'es'} (${t.device.matrixSummary()}).`);
285
+ if (t.device.isBricked) {
286
+ lines.push(t.device.kind === 'lock'
287
+ ? ` ${t.device.name} BRICKS -- sparks, a pop, a smell of hot plastic. And the lock STAYS LOCKED (p.228): a dead maglock holds like a bolt.${hint(` (Marks and "disable" would have opened it; now it is "pick" or "breach".)`)}`
288
+ : ` ${t.device.name} BRICKS -- sparks, a pop, dead electronics. Whatever it was doing, it has stopped.`);
289
+ this.scene.addWorldEvent(`${actor.name} bricked ${t.device.name} in ${t.room.name} with a Data Spike.`);
290
+ }
291
+ }
292
+ else if (t.kind === 'camera') {
293
+ // The cluster has no Device of its own to hold a monitor; a hit
294
+ // that lands past the firewall kills it. Engine ruling.
295
+ t.room.camerasBricked = true;
296
+ lines.push(`The spike lands on the cluster -- ${dealt} box${dealt === 1 ? '' : 'es'} -- and every lens on ${t.room.name} goes to static and stays there. Dead glass.`);
297
+ this.scene.addWorldEvent(`${actor.name} bricked the cameras on ${t.room.name} with a Data Spike.`);
298
+ }
299
+ else {
300
+ // A gun is a Rating 2 device with a monitor of 9 (p.228, the
301
+ // book's own smartgun example): a spike that lands past its
302
+ // firewall for that much bricks it outright.
303
+ const monitor = 8 + 1;
304
+ if (dealt >= monitor) {
305
+ t.gun.bricked = true;
306
+ lines.push(`The spike lands on ${t.owner.name}'s ${t.gun.name} -- it sparks, crackles and smokes in their hand. BRICKED: it will not fire again until somebody's bench work.`);
307
+ t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} smoking in their hand`);
308
+ }
309
+ else {
310
+ t.gun.jammed = true;
311
+ lines.push(`The spike lands on ${t.owner.name}'s ${t.gun.name} -- ${dealt} box${dealt === 1 ? '' : 'es'}: the smartlink seizes and LOCKS. It boots again on a reboot; another spike like that bricks it.`);
312
+ t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} locking up in their hand`);
313
+ }
314
+ this.scene.addWorldEvent(`${actor.name} spiked ${t.owner.name}'s ${t.gun.name}.`);
315
+ }
316
+ this.scene.updateStatus();
317
+ return [...lines, ...godLines].join('\n');
318
+ }
319
+ }
320
+ /** `brick <icon>`: the Data Spike, by its street name. */
321
+ export class BrickCommand extends DisableCommand {
322
+ static verb = 'brick';
323
+ static description = 'Data Spike (SR5 p.239): Cybercombat + Logic [Attack] v. Device Rating + Firewall, no marks needed, never subtle. Fill a device\'s Matrix condition monitor (8 + DR/2, p.228) and it BRICKS -- a camera goes dead, a smartgun sparks and dies; a lock stays locked. A miss burns your own deck.';
324
+ way(args) {
325
+ const rest = [...args];
326
+ if (rest[0]?.toLowerCase() === 'loud')
327
+ rest.shift();
328
+ return { way: 'spike', rest };
329
+ }
330
+ }
331
+ //# sourceMappingURL=disable.js.map
@@ -3,6 +3,7 @@ import { movePersonaWith } from '../utilities/matrix-roster.js';
3
3
  import { resolveHostInReach, hostsInReach, gridVicinity, hostOver } from '../utilities/grid-reach.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { hostLabel } from '../utilities/grid-names.js';
6
+ import { billAction } from '../utilities/action-cost.js';
6
7
  /**
7
8
  * ENTER/EXIT HOST (SR5 p.239, Complex Action).
8
9
  *
@@ -67,6 +68,12 @@ export class EnterHostCommand extends Command {
67
68
  hint(`A host opens to anyone holding a MARK on it (p.239) -- "mark ${target.name}" softens it, or "hack" takes one by force.`),
68
69
  ].join('\n');
69
70
  }
71
+ // A Complex Action (p.239) -- billed only inside a Combat Turn.
72
+ const bill = billAction(this.scene, actor, 'complex', 'Enter Host');
73
+ if (bill)
74
+ return bill;
75
+ // Crossing the wall leaves whatever fight was on this side of it.
76
+ this.scene.encounterFor?.(actor)?.leave(actor);
70
77
  // THE PERSONA CROSSES, THE BODY STAYS (increment 2): position becomes
71
78
  // the host, remembering the grid it came from (p.239: exit returns
72
79
  // you there). currentLocation is never written -- it is the body's.
@@ -130,6 +137,13 @@ export class ExitHostCommand extends Command {
130
137
  const by = [...actor.linkLockedBy].join(', ');
131
138
  return `{red-fg}You reach for the door and it isn't there. LINK-LOCKED by ${by}: the connection is held open and Enter/Exit Host is closed to you (p.229). "jack out" still works -- it just costs you dumpshock.{/red-fg}`;
132
139
  }
140
+ // A Complex Action (p.239), and the way out of the host's fight
141
+ // (utilities/host-combat.ts): a persona back on the grid is beyond
142
+ // the ice, which "does not operate out on the grid".
143
+ const bill = billAction(this.scene, actor, 'complex', 'Exit Host');
144
+ if (bill)
145
+ return bill;
146
+ this.scene.encounterFor?.(actor)?.leave(actor);
133
147
  const host = actor.insideHost;
134
148
  const from = actor.matrixPosition?.kind === 'host' ? actor.matrixPosition.from : undefined;
135
149
  actor.matrixPosition = { kind: 'grid', grid: from };
@@ -12,6 +12,7 @@ import { matchesCameraName, hostLabel } from '../utilities/grid-names.js';
12
12
  import { TapCommand } from './tap.js';
13
13
  import { sameHostSide } from '../models/player.js';
14
14
  import { MAX_MARKS } from '../utilities/marks.js';
15
+ import { billAction } from '../utilities/action-cost.js';
15
16
  import { declarationPenalty, parseMarkDeclaration, hostDefensePool, wanDefensePool, freeMatrixPerceptionHits, bruteForceMatrixDv, overwatchFromDefense, GO_BIG_QUALITY, deviceDefensePool } from '../utilities/matrix-intrusion.js';
16
17
  /**
17
18
  * MARKS A PAN INTRUSION MUST ALREADY HOLD before it can command the
@@ -74,6 +75,12 @@ export class HackCommand extends BypassCommand {
74
75
  // here so a declaration never lands in a target name, the same way
75
76
  // the mode words are.
76
77
  const { marks: declaredMarks, rest } = parseMarkDeclaration(afterMode);
78
+ // BRUTE FORCE and HACK ON THE FLY are Complex Actions (p.238, p.240)
79
+ // -- billed only inside a Combat Turn, where one intrusion is the
80
+ // whole Action Phase.
81
+ const bill = billAction(this.scene, this.actor, 'complex', mode === 'sleaze' ? 'Hack on the Fly' : 'Brute Force');
82
+ if (bill)
83
+ return bill;
77
84
  // PAN warfare: "hack <name>" cracks a meat actor's personal area
78
85
  // network -- from inside the Matrix, or right here in AR with a
79
86
  // working deck in hand (which is also how enemy deckers get YOU).
@@ -601,16 +608,16 @@ export class HackCommand extends BypassCommand {
601
608
  if (perception > 0)
602
609
  lines.push(` {cyan-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what it guards (p.240).{/cyan-fg}`);
603
610
  }
604
- // ONE MARK COMMANDS IT. Opening a door is a Free action, and p.238
605
- // puts a Free action at one mark -- so the key IS the door, and
606
- // there is no second roll to make.
607
- if (now >= 1 && !device.isOpen()) {
608
- // NAME ONLY A VERB THAT DISPATCHES. An earlier draft offered
609
- // "control <device>", which is not a command this game has --
610
- // caught by advertised-verbs-structured.test.ts, which exists for
611
- // exactly that. `open` is the real one, and it is a MEAT verb: the
612
- // key is placed from in here, the door swings out there.
613
- lines.push(` That is enough to work it: ${device.opensExit ? `the ${device.opensExit} way` : device.name} answers to you now.${hint(` ("open ${device.opensExit ?? device.name}" once your body is back in the room.)`)}`);
611
+ // TWO MARKS COMMAND IT (p.238 Control Device: 1 mark for a Free
612
+ // action, 2 for a Simple, 3 for a Complex -- and unlocking a maglock
613
+ // is the book's own example of the no-test device action, a Simple
614
+ // "Use Simple Device", p.165). The verb is `disable`
615
+ // (commands/disable.ts): the key is turned from in here, the door
616
+ // swings out there. One mark short says so.
617
+ if (!device.isOpen()) {
618
+ lines.push(now >= 2
619
+ ? ` That is enough to command it: ${hint(`"disable ${device.name}" (Control Device, p.238) ${device.opensExit ? `opens the ${device.opensExit} way` : `works it`} from right here.`)}`
620
+ : ` One more mark and it takes orders.${hint(` ("hack ${device.name}" again, then "disable ${device.name}" -- Control Device wants 2 marks for a Simple device action, p.238.)`)}`);
614
621
  }
615
622
  this.scene.updateStatus();
616
623
  return [...lines, ...godLines].join('\n');
@@ -3,6 +3,7 @@ import { gridVicinity } from '../utilities/grid-reach.js';
3
3
  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
+ import { billAction } from '../utilities/action-cost.js';
6
7
  /**
7
8
  * The Matrix door, both directions: "jack in [hot|cold]" and "jack out".
8
9
  * Needs a working CYBERDECK carried (a commlink is calls and AR only) --
@@ -97,6 +98,11 @@ export class JackCommand extends Command {
97
98
  if (actor.inExchange) {
98
99
  return `The ice has your persona locked in the exchange -- survive it first, then jack out in the lull.`;
99
100
  }
101
+ // JACK OUT is a Simple Action (p.240) -- billed inside a Combat
102
+ // Turn; leaving the Matrix then leaves the fight (planes.ts).
103
+ const bill = billAction(this.scene, actor, 'simple', 'Jack Out');
104
+ if (bill)
105
+ return bill;
100
106
  // LINK-LOCKED: leaving stops being a decision and becomes a test
101
107
  // (p.229/p.240). Everything below is the Jack Out action.
102
108
  if (actor.linkLockedBy.size > 0) {
@@ -17,6 +17,7 @@ import { BULLET } from '../utilities/log-style.js';
17
17
  import { spotsActive, whoIsWhereLine, highlightSpots, highlightActors, canReach, sealedRouteTo } from '../utilities/spots.js';
18
18
  import { rollPool, formatRoll } from '../utilities/dice.js';
19
19
  import { findCatalogItem, describeEffect } from '../utilities/catalog.js';
20
+ import { billAction } from '../utilities/action-cost.js';
20
21
  export class LookCommand extends Command {
21
22
  static verb = 'look';
22
23
  static description = 'Examine your surroundings';
@@ -301,6 +302,11 @@ export class LookCommand extends Command {
301
302
  * match the grid map: ● host, ◆ persona, ▣ PAN, ◇ data.
302
303
  */
303
304
  gridLook(room) {
305
+ // MATRIX PERCEPTION is a Complex Action (p.241) -- billed only inside
306
+ // a Combat Turn, where a look at the room costs the phase.
307
+ const bill = billAction(this.scene, this.actor, 'complex', 'Matrix Perception');
308
+ if (bill)
309
+ return bill;
304
310
  // NO MEAT PROSE AT ALL (player ruling 2026-08-26: "looking in the
305
311
  // matrix shouldn't show the meatworld"). The room's own description
306
312
  // used to open this view "for orientation" -- but that description
@@ -163,6 +163,19 @@ export class RestCommand extends Command {
163
163
  lines.push(` Bench work on the ${deck.name}: ${formatRoll(repairRoll)}`);
164
164
  lines.push(` ${repaired} box${repaired === 1 ? '' : 'es'} repaired: ${deck.deckSummary()}${wasBricked && !deck.isBricked ? ' -- it boots again.' : ''}`);
165
165
  }
166
+ // BRICKED GEAR (p.228 Repairing Matrix Damage): an hour with a
167
+ // toolkit and Hardware + Logic -- the rest is the hour, and every
168
+ // hit either clears boxes or halves the time, so by the end of a
169
+ // night's rest a bricked smartgun boots again. A reboot never does
170
+ // this; that is the difference between bricked and jammed.
171
+ const brickedGear = [
172
+ ...actor.inventory.getAllItems(),
173
+ actor.equipment.rightHand?.weapon, actor.equipment.leftHand?.weapon,
174
+ ].filter((i) => !!i && i.bricked);
175
+ for (const gear of new Set(brickedGear)) {
176
+ gear.bricked = false;
177
+ lines.push(` Bench work on the ${gear.name}: the burnt boards come out, and it powers up again.`);
178
+ }
166
179
  for (const drone of damagedDrones) {
167
180
  const wasWrecked = drone.isWrecked;
168
181
  const repairRoll = rollPool(actor.logic + actor.skillRating('hardware') + actor.bonus('piloting'));