@maka/maka-cli 5.182.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.182.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.",
@@ -218,6 +218,9 @@ export class AttackCommand extends Command {
218
218
  // holstered dry/jammed piece is not what's being attacked with, so it
219
219
  // must not block a bare-fisted attack the player never asked to fail.
220
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
+ }
221
224
  if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.jammed) {
222
225
  return `The ${weaponItem.name}'s smartlink is LOCKED -- someone cracked your PAN. "reboot" to clear it, or go in swinging.`;
223
226
  }
@@ -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
@@ -608,16 +608,16 @@ export class HackCommand extends BypassCommand {
608
608
  if (perception > 0)
609
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}`);
610
610
  }
611
- // ONE MARK COMMANDS IT. Opening a door is a Free action, and p.238
612
- // puts a Free action at one mark -- so the key IS the door, and
613
- // there is no second roll to make.
614
- if (now >= 1 && !device.isOpen()) {
615
- // NAME ONLY A VERB THAT DISPATCHES. An earlier draft offered
616
- // "control <device>", which is not a command this game has --
617
- // caught by advertised-verbs-structured.test.ts, which exists for
618
- // exactly that. `open` is the real one, and it is a MEAT verb: the
619
- // key is placed from in here, the door swings out there.
620
- 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.)`)}`);
621
621
  }
622
622
  this.scene.updateStatus();
623
623
  return [...lines, ...godLines].join('\n');
@@ -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'));
@@ -2,7 +2,9 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { planeTintItemName, emphasizeFind, objectiveMark, fileReach } from '../utilities/planes.js';
4
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
5
- import { perceptionTest } from '../utilities/perception.js';
5
+ import { perceptionTest, matrixSearchTest, matrixSearchSeconds, MATRIX_SEARCH } from '../utilities/perception.js';
6
+ import { gridVicinity } from '../utilities/grid-reach.js';
7
+ import { hostLabel } from '../utilities/grid-names.js';
6
8
  import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
7
9
  import { placeName } from '../utilities/log-style.js';
8
10
  import { billAction, encounterOf } from '../utilities/action-cost.js';
@@ -135,6 +137,73 @@ ${coach}`;
135
137
  export class SearchCommand extends Command {
136
138
  static verb = 'search';
137
139
  static description = 'Search for items in your area';
140
+ /**
141
+ * MATRIX SEARCH (SR5 p.241, player ruling 2026-09-13: "a true matrix
142
+ * search, not perception, revealing what is inside a host if it's
143
+ * cracked open").
144
+ *
145
+ * A Special action: Computer + Intuition [Data Processing] against a
146
+ * threshold from the Matrix Search Table, and it takes TIME -- one
147
+ * minute inside a host whatever the information, net hits over the
148
+ * threshold dividing it, a miss spending it all, a Browse program
149
+ * halving it (utilities/perception.ts). The time is real: it moves
150
+ * the persona's Overwatch clock back, so a slow search inside a host
151
+ * is what GOD bills for (utilities/overwatch.ts billTime, +2d6 per
152
+ * 15 minutes).
153
+ *
154
+ * WHAT IT FINDS. Inside a host, the host's archive: from then on the
155
+ * interior lists its files for this persona (Host.searchedBy,
156
+ * grid-view.ts). A CRACKED host spills them without the roll -- the
157
+ * seal is broken, there is nothing to find. Out on the grid it is the
158
+ * public-knowledge row: the loose data icons in reach.
159
+ */
160
+ matrixSearch() {
161
+ const actor = this.actor;
162
+ const host = actor.hostInside;
163
+ const where = host ? host.over : gridVicinity(actor);
164
+ const row = host ? MATRIX_SEARCH.inHost : MATRIX_SEARCH.public;
165
+ const browse = actor.activeDeck?.hasProgram('browse') ?? false;
166
+ const { pool, limit } = matrixSearchTest(actor);
167
+ const roll = rollPool(pool, limit);
168
+ const seconds = matrixSearchSeconds(row.seconds, roll.hits, row.threshold, browse);
169
+ const isPlayer = this.scene.isHumanControlled(actor);
170
+ if (isPlayer) {
171
+ this.logger.meta(`Matrix Search (Computer + Intuition [Data Processing], threshold ${row.threshold}${browse ? ', Browse' : ''}): ${formatRoll(roll)} -- ${seconds}s`);
172
+ }
173
+ // THE MINUTES ARE GOD'S (p.231-232): Overwatch bills time on the
174
+ // grid, and a search is time. Winding the last tick back by the
175
+ // seconds spent is what makes the next illegal action pay for them.
176
+ if (actor.overwatchLastTick > 0)
177
+ actor.overwatchLastTick -= seconds * 1000;
178
+ actor.performAction('runs a Matrix Search', host ? `inside ${hostLabel(where)}` : `across ${where.name}'s grid`);
179
+ const files = where.offlineServer ? [] : where.inventory.getAllItems().filter(i => i.plane === 'matrix');
180
+ const clock = `(${seconds}s of Matrix time${browse ? ', Browse cutting it' : ''})`;
181
+ if (roll.hits < row.threshold) {
182
+ return host
183
+ ? `You dig through ${hostLabel(where)}'s archive and the index keeps folding away from you -- nothing this pass. ${clock}${hint(` Try again; the host is not going anywhere.`)}`
184
+ : `You sweep ${where.name}'s grid for loose data and turn up nothing this pass. ${clock}`;
185
+ }
186
+ if (host) {
187
+ host.searchedBy.add(actor.name);
188
+ where.searchedBy.add(actor.name);
189
+ if (files.length === 0) {
190
+ return `The archive is indexed and EMPTY -- ${hostLabel(where)} keeps nothing worth the trip. ${clock}`;
191
+ }
192
+ const list = files.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
193
+ const seal = where.hostCracked
194
+ ? hint(` ("take" them -- the seal is already broken.)`)
195
+ : hint(` (sealed in the host's data vault: "hack" the node to break the seal, then "take".)`);
196
+ return `The index gives up ${hostLabel(where)}'s archive: ${list}. ${clock}${seal}`;
197
+ }
198
+ where.searchedBy.add(actor.name);
199
+ const loose = where.hasNode ? [] : files;
200
+ if (loose.length === 0) {
201
+ const sealedElsewhere = files.length > 0 && where.hasNode;
202
+ return `Nothing loose on ${where.name}'s grid. ${clock}${sealedElsewhere ? hint(` (${hostLabel(where)} keeps its files INSIDE -- "enter" it and search from in there.)`) : ''}`;
203
+ }
204
+ const list = loose.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
205
+ return `Loose on ${where.name}'s grid: ${list}. ${clock}${hint(` ("take" rides them out on your deck.)`)}`;
206
+ }
138
207
  async execute(_args) {
139
208
  const room = this.actor.currentLocation;
140
209
  // A MATRIX SEARCH IS MEASURED IN MINUTES (p.241: base time one
@@ -144,6 +213,8 @@ export class SearchCommand extends Command {
144
213
  if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
145
214
  return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice or get out first.`;
146
215
  }
216
+ if (this.actor.plane === 'matrix')
217
+ return this.matrixSearch();
147
218
  // OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
148
219
  // Simple Action the book names, inside a Combat Turn.
149
220
  const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
@@ -155,17 +226,14 @@ export class SearchCommand extends Command {
155
226
  // room stays uncased ("take all" stays gated). Barriers (puzzles) are
156
227
  // in plain sight and show regardless -- you can't fail to notice the
157
228
  // maglock you're staring at.
158
- // On the GRID a search is Matrix Search (canon: Computer, Logic) --
159
- // best-of with perception so pre-computer deckers lose nothing.
160
- // A loaded Browse program is purpose-built for Matrix Search: +2.
161
- const browse = this.actor.plane === 'matrix' && this.actor.activeDeck?.hasProgram('browse') ? 2 : 0;
162
229
  // ACTIVE: this is the deliberate hunt, so it carries p.135-136's +3
163
230
  // for "perceiver is specifically looking for it" -- the modifier
164
231
  // that makes `search` mechanically different from `look` rather
165
232
  // than only differently named (FNgptvRhbA5yMunbB). The pool and the
166
233
  // bracket both live in utilities/perception.ts; look.ts rolls the
167
- // same test without the +3.
168
- const { pool, limit } = perceptionTest(this.actor, { active: true, browse });
234
+ // same test without the +3. The grid never comes through here: a
235
+ // persona's search is matrixSearch() above.
236
+ const { pool, limit } = perceptionTest(this.actor, { active: true });
169
237
  const roll = rollPool(pool, limit);
170
238
  // Roll anatomy to the Mechanics ticker for the player; an NPC's
171
239
  // return string is its AI history, so IT keeps the roll inline
@@ -234,28 +302,7 @@ export class SearchCommand extends Command {
234
302
  if (spotted.length > 0) {
235
303
  description += `\n{yellow-fg}You pick ${spotted.map(a => a.name).join(', ')} out of the shadows -- ${spotted.length === 1 ? 'they were' : 'they were'} there the whole time.{/yellow-fg}`;
236
304
  }
237
- // Data is LOOT -- say so while the persona is standing on it, with the
238
- // vault state attached: sealed files need the host cracked first. A
239
- // real session's decker found the paydata jacked-in, didn't realize it
240
- // was takeable from the grid, and went hunting for it in the meat.
241
- if (this.actor.plane === 'matrix' && found.some(i => i.plane === 'matrix')) {
242
- description += room.hasNode && !room.offlineServer && !room.hostCracked
243
- ? `\n{cyan-fg}(the files sit sealed in the host's data vault -- "hack" the node to unseal them){/cyan-fg}`
244
- : hint(`\n{cyan-fg}(data files are loot -- "take" them right here on the grid; they ride out on your deck){/cyan-fg}`);
245
- }
246
- // A persona sweeping the grid OUTSIDE a host: the files exist and
247
- // this sweep cannot reach them. Say why, rather than reading empty
248
- // -- a silent nothing is what made the old air-gap case feel broken.
249
- if (this.actor.plane === 'matrix' && !filesInReach
250
- && room.inventory.getAllItems().some(i => i.plane === 'matrix')) {
251
- if (room.offlineServer) {
252
- description += `\n{cyan-fg}There is a ${room.offlineServer} in this room and it is NOT on the Matrix -- nothing you can do from out here touches it.${hint(` Walk in and cable into it in the flesh.`)}{/cyan-fg}`;
253
- }
254
- else {
255
- const marked = (room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked;
256
- description += `\n{cyan-fg}This sweep only covers the open grid. Whatever the host holds is INSIDE it, behind its wall (p.246).${hint(marked ? ` "enter" and search again.` : ` A mark is the door -- "mark" the host, then "enter".`)}{/cyan-fg}`;
257
- }
258
- }
305
+ // The grid never reaches here: a persona's search is matrixSearch().
259
306
  // The mirror hint: a meat-side search can't see matrix files, but it
260
307
  // shouldn't pretend the room is empty when the good stuff is one
261
308
  // "jack in" away.
@@ -109,6 +109,7 @@ import { EndCallCommand } from './commands/end-call.js';
109
109
  import { fuzzyPickName, guessVerb } from './utilities/fuzzy-match.js';
110
110
  import { PickCommand } from './commands/pick.js';
111
111
  import { HackCommand } from './commands/hack.js';
112
+ import { DisableCommand, BrickCommand } from './commands/disable.js';
112
113
  import { CastCommand } from './commands/cast.js';
113
114
  import { StanceCommand } from './commands/stance.js';
114
115
  import { FollowCommand, UnfollowCommand } from './commands/follow.js';
@@ -2596,6 +2597,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2596
2597
  CommandFactory.registerCommand('end-call', EndCallCommand);
2597
2598
  CommandFactory.registerCommand('pick', PickCommand);
2598
2599
  CommandFactory.registerCommand('hack', HackCommand);
2600
+ CommandFactory.registerCommand('disable', DisableCommand);
2601
+ CommandFactory.registerCommand('brick', BrickCommand);
2599
2602
  CommandFactory.registerCommand('cast', CastCommand);
2600
2603
  CommandFactory.registerCommand('stance', StanceCommand);
2601
2604
  CommandFactory.registerCommand('surrender', SurrenderCommand);
@@ -6275,7 +6278,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6275
6278
  // players" / "crew street"); the bare verb still answers quietly.
6276
6279
  { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
6277
6280
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
6278
- { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6281
+ { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6279
6282
  // The Emerged get their own shelf (player request: "there MUST be
6280
6283
  // resonance" -- matrix is the place, Resonance is the talent).
6281
6284
  // `sustain` shelves here and not under magic: the command is
@@ -126,6 +126,16 @@ export class Device {
126
126
  structure;
127
127
  armor;
128
128
  _barrierDamage = 0;
129
+ /**
130
+ * THE MATRIX CONDITION MONITOR (p.228): 8 + (Device Rating / 2)
131
+ * boxes, resisted with Device Rating + Firewall, filled by a Data
132
+ * Spike (commands/disable.ts, "brick"). Full = BRICKED: "the device
133
+ * ceases functioning" -- a camera records nothing, a smartgun sparks
134
+ * and dies -- but "a lock stays locked" (p.228): bricking is never a
135
+ * way through a maglock. Repaired on the bench (rest.ts), never by a
136
+ * reboot. A ward is not electronics and has no monitor at all.
137
+ */
138
+ _matrixDamage = 0;
129
139
  /**
130
140
  * THE LOCKSMITH EXTENDED TEST IN PROGRESS (p.48, p.359-360): picking a
131
141
  * maglock by hand accumulates hits toward Rating x 2 across attempts,
@@ -207,6 +217,33 @@ export class Device {
207
217
  * exact fault this codebase keeps having to close (see the gun in
208
218
  * aroTags, AKfemBJJWJP99nx8K).
209
219
  */
220
+ // ---- The Matrix condition monitor (commands/disable.ts) ----
221
+ /** p.228: 8 + (Device Rating / 2), rounded up as the deck's is. 0 for
222
+ * a thing with no rating (a ward): nothing there to brick. */
223
+ get matrixMonitor() {
224
+ return this.rating > 0 ? 8 + Math.ceil(this.rating / 2) : 0;
225
+ }
226
+ get matrixDamage() {
227
+ return this._matrixDamage;
228
+ }
229
+ /** Returns boxes actually taken. */
230
+ takeMatrixDamage(boxes) {
231
+ if (this.matrixMonitor <= 0)
232
+ return 0;
233
+ const taken = Math.max(0, Math.floor(boxes));
234
+ this._matrixDamage = Math.min(this.matrixMonitor, this._matrixDamage + taken);
235
+ return taken;
236
+ }
237
+ get isBricked() {
238
+ return this.matrixMonitor > 0 && this._matrixDamage >= this.matrixMonitor;
239
+ }
240
+ /** Bench work (rest.ts): the whole monitor, at once. */
241
+ repairMatrix() {
242
+ this._matrixDamage = 0;
243
+ }
244
+ matrixSummary() {
245
+ return this.matrixMonitor > 0 ? `${this._matrixDamage}/${this.matrixMonitor} boxes` : '';
246
+ }
210
247
  broadcastsAro() {
211
248
  return this.wireless && this.rating > 0;
212
249
  }
@@ -328,11 +365,13 @@ export class Device {
328
365
  const text = hint(AFFORDANCE_FLAVOR[this.kind].hint);
329
366
  return text.length > 0 ? text : undefined;
330
367
  }
331
- /** Persistence-only: one flag in, nothing out. */
368
+ /** Persistence-only: flags in, nothing out. */
332
369
  restoreState(state) {
333
370
  this.open = state.open;
334
371
  if (state.damage !== undefined)
335
372
  this._barrierDamage = Math.max(0, Math.min(this.structure, Math.floor(state.damage)));
373
+ if (state.bricked !== undefined)
374
+ this._matrixDamage = state.bricked ? this.matrixMonitor : 0;
336
375
  }
337
376
  }
338
377
  //# sourceMappingURL=device.js.map
@@ -41,6 +41,10 @@ export class Host {
41
41
  marksBy = new Map();
42
42
  /** player name -> marks the host holds on that player. */
43
43
  marksOn = new Map();
44
+ /** Personas whose Matrix Search (p.241, commands/search.ts) turned up
45
+ * this host's files: the archive is listed for them from then on. A
46
+ * cracked host lists it for everyone. Session-only. */
47
+ searchedBy = new Set();
44
48
  /** Walls up: the host holds a mark on an intruder and its ice is hunting. */
45
49
  alert = false;
46
50
  /** IC running now, by kind name. Names, not actors -- until increment 5. */
@@ -43,6 +43,12 @@ export class Item extends AbstractItem {
43
43
  // A jammed firearm can't fire (fists until cleared); "reboot" clears
44
44
  // every jammed device you carry, in the lull.
45
45
  jammed = false;
46
+ // BRICKED (p.228, commands/disable.ts "brick"): a Data Spike filled the
47
+ // device's Matrix condition monitor. Dead electronics -- a smartgun
48
+ // "sparks, crackles, and smokes" and will not fire -- until bench
49
+ // work (rest.ts). A reboot does NOT clear it; that is the whole
50
+ // difference from `jammed`.
51
+ bricked = false;
46
52
  _transferable;
47
53
  // New currency-related fields
48
54
  _currencyAmount = 0;
@@ -469,6 +469,12 @@ export class Room extends AbstractRoom {
469
469
  warded = false;
470
470
  // See IRoomConstructorConfig.watched (utilities/surveillance.ts).
471
471
  watched = false;
472
+ /** The cluster is LOOPED by Control Device (commands/disable.ts): the
473
+ * house sees empty hallways until the run ends. Session-only. */
474
+ camerasLooped = false;
475
+ /** The cluster is BRICKED by a Data Spike (p.228): dead lenses, until
476
+ * somebody's bench work nobody in this run is doing. Session-only. */
477
+ camerasBricked = false;
472
478
  /**
473
479
  * THE HOST STANDING OVER THIS ROOM, as an object (models/host.ts) --
474
480
  * increment 1 of the Matrix position model. Every host field this class
@@ -16,6 +16,25 @@ import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
16
16
  * ▣ PAN, ▲ dangerous device, ◇ data. (There is no air-gapped-host glyph
17
17
  * any more -- see hhSWzLSXECFtfoAGa and canReachHost.)
18
18
  */
19
+ /**
20
+ * A DEVICE ICON, AND WHAT WORKS IT (commands/disable.ts, 2026-09-13).
21
+ * Control Device (p.238) needs 2 marks on the device -- "disable" -- and
22
+ * a Data Spike (p.239) needs none -- "brick". A bricked lock stays
23
+ * locked (p.228), so its line says so rather than offering a way in.
24
+ */
25
+ export function deviceIconLine(device, actor) {
26
+ const dr = `{bold}[DR ${device.rating}]{/bold}`;
27
+ if (device.isOpen())
28
+ return `▤ ${device.name} ${dr} -- its icon standing open, nothing left to hold.`;
29
+ if (device.isBricked) {
30
+ return `▤ ${device.name} ${dr} -- BRICKED, dead electronics${device.kind === 'lock' ? '; the lock stays locked (p.228)' : ''}.`;
31
+ }
32
+ const marks = device.marksBy.get(actor.name) ?? 0;
33
+ const way = marks >= 2
34
+ ? `"disable ${device.name}" (Control Device, ${marks} marks held)`
35
+ : `"hack ${device.name}" for marks (${marks}/2 to command it), or "brick ${device.name}"`;
36
+ return `▤ ${device.name} ${dr} -- a device icon shaped like the thing itself, shut. (${way})`;
37
+ }
19
38
  export function gridIcons(scene, actor, room) {
20
39
  // INSIDE A HOST IS A DIFFERENT PLACE (SR5 p.246, and Deditri's
21
40
  // eJANPpm7qCaCAJBJw). "The virtual space inside a host is separate
@@ -202,9 +221,7 @@ export function gridIcons(scene, actor, room) {
202
221
  for (const device of room.devices) {
203
222
  if (!device.broadcastsAro())
204
223
  continue;
205
- icons.push(device.isOpen()
206
- ? `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- its icon standing open, nothing left to hold.`
207
- : `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- a device icon shaped like the thing itself, shut. ("hack ${device.name}")`);
224
+ icons.push(deviceIconLine(device, actor));
208
225
  }
209
226
  // The cameras, as a THING (surveillance.ts): live for security, looped,
210
227
  // or yours to ride.
@@ -306,7 +323,16 @@ function hostInteriorIcons(scene, actor, room) {
306
323
  const data = room.offlineServer
307
324
  ? []
308
325
  : room.inventory.getAllItems().filter(i => i.plane === 'matrix');
309
- for (const item of data) {
326
+ // THE ARCHIVE IS NOT A GLANCE (p.241, player ruling 2026-09-13): a
327
+ // persona sees the host's files after a Matrix Search turns them up
328
+ // (commands/search.ts, Host.searchedBy), or when the host is cracked
329
+ // and they spill open. Until then a look says only that there is an
330
+ // archive to search.
331
+ const found = room.hostCracked || (room.host?.searchedBy.has(actor.name) ?? false);
332
+ if (data.length > 0 && !found) {
333
+ icons.push(`◇ The host's archive -- files in here somewhere, and a glance does not read an archive. ("search" runs a Matrix Search, p.241)`);
334
+ }
335
+ for (const item of found ? data : []) {
310
336
  icons.push(room.hostCracked
311
337
  ? `◇ ${item.name} [data] -- unsealed, sitting open in the host's archive. ("take" it)`
312
338
  : `◇ ${item.name} [data] -- SEALED in the host's archive. ("hack" the host to break the seal)`);
@@ -39,36 +39,65 @@ import { mentalLimit } from './limits.js';
39
39
  * an active search mechanically different from a passive glance. */
40
40
  export const ACTIVELY_LOOKING = 3;
41
41
  /**
42
- * ON THE GRID IT IS A DIFFERENT TEST WITH A DIFFERENT BRACKET. Matrix
43
- * Search is Computer + Logic [Data Processing], so the Mental limit does
44
- * NOT apply out there -- capping a decker's sweep by their Willpower
45
- * would be a fresh deviation dressed as a fix. The engine takes the
46
- * best of Perception and Computer so pre-Computer deckers lose nothing
47
- * (a standing allowance, older than this function).
42
+ * THE MEAT PERCEPTION TEST. The Matrix used to ride through here as a
43
+ * "different test with a different bracket" -- Logic + the better of
44
+ * Perception and Computer [Data Processing] -- which was neither Matrix
45
+ * Perception nor Matrix Search. It is matrixSearchTest now, below; the
46
+ * grid never comes through this door.
47
+ */
48
+ export function perceptionTest(actor, opts = {}) {
49
+ const skill = actor.skillRating('perception');
50
+ // Unskilled defaults at -1 (p.130), exactly as both callers already did.
51
+ const pool = Math.max(1, actor.intuition
52
+ + (skill > 0 ? skill : -1)
53
+ + (opts.active ? ACTIVELY_LOOKING : 0)
54
+ + actor.bonus('perception')
55
+ + actor.woundModifier
56
+ - actor.sustainingPenalty);
57
+ return { pool, limit: mentalLimit(actor) };
58
+ }
59
+ /**
60
+ * MATRIX SEARCH (SR5 p.241, RAG-checked 2026-09-13): a Special action,
61
+ * "Computer + Intuition [Data Processing]" -- INTUITION, not Logic; the
62
+ * old grid branch above rolled Logic and called it canon. The threshold
63
+ * and the base time come from what is being looked for (the Matrix
64
+ * Search Table: public 1 / 1 minute, limited 3 / 30 minutes; inside a
65
+ * host the base time is one minute whatever the information), net hits
66
+ * over the threshold divide the time, and a failure still spends all
67
+ * of it. A Browse program "cuts the base time in half" (Splintered
68
+ * State p.54) -- time, not dice. Running silent costs its -2 like every
69
+ * Matrix action (p.235-236).
48
70
  *
49
71
  * Data Processing reads 0 for an actor with no deck at all; that is a
50
72
  * caller's missing gate rather than a real limit, so it degrades to
51
73
  * unlimited here instead of handing rollPool a zero it would reject.
52
74
  */
53
- export function perceptionTest(actor, opts = {}) {
54
- const onGrid = actor.plane === 'matrix';
55
- const skill = onGrid
56
- ? Math.max(actor.skillRating('perception'), actor.skillRating('computer'))
57
- : actor.skillRating('perception');
58
- const attribute = onGrid ? actor.logic : actor.intuition;
59
- // Unskilled defaults at -1 (p.130), exactly as both callers already did.
60
- const pool = Math.max(1, attribute
75
+ export function matrixSearchTest(actor) {
76
+ const skill = actor.skillRating('computer');
77
+ const pool = Math.max(1, actor.intuition
61
78
  + (skill > 0 ? skill : -1)
62
- + (opts.browse ?? 0)
63
- + (opts.active ? ACTIVELY_LOOKING : 0)
64
79
  + actor.bonus('perception')
80
+ + (typeof actor.matrixActionPenalty === 'number' ? actor.matrixActionPenalty : 0)
65
81
  + actor.woundModifier
66
82
  - actor.sustainingPenalty);
67
- const dataProcessing = onGrid ? actor.matrixAttribute('dataProcessing') : 0;
68
- const limit = onGrid
69
- ? (dataProcessing > 0 ? dataProcessing : undefined)
70
- : mentalLimit(actor);
71
- return { pool, limit };
83
+ const dataProcessing = actor.matrixAttribute('dataProcessing');
84
+ return { pool, limit: dataProcessing > 0 ? dataProcessing : undefined };
85
+ }
86
+ /** The Matrix Search Table (p.241-242): threshold and base time in
87
+ * seconds. Inside a host the base time is one minute regardless. */
88
+ export const MATRIX_SEARCH = {
89
+ public: { threshold: 1, seconds: 60 },
90
+ inHost: { threshold: 3, seconds: 60 },
91
+ };
92
+ /** How long a search takes once rolled: the base time divided by the
93
+ * net hits over the threshold, never under one Combat Turn (3s); a
94
+ * failure spends the whole base time. Browse halves the base. */
95
+ export function matrixSearchSeconds(baseSeconds, hits, threshold, browse) {
96
+ const base = browse ? Math.ceil(baseSeconds / 2) : baseSeconds;
97
+ if (hits < threshold)
98
+ return base;
99
+ const net = hits - threshold;
100
+ return net > 0 ? Math.max(3, Math.ceil(base / net)) : base;
72
101
  }
73
102
  /**
74
103
  * PERCEPTION THRESHOLDS (p.136). Named because the numbers appear in
@@ -164,6 +164,8 @@ export function serializeItem(item) {
164
164
  out.loadedAmmo = item.loadedAmmo;
165
165
  if (item.jammed)
166
166
  out.jammed = true;
167
+ if (item.bricked)
168
+ out.bricked = true;
167
169
  if (item.deckDamage > 0)
168
170
  out.deckDamage = item.deckDamage;
169
171
  if (item.droneDamage > 0)
@@ -219,6 +221,8 @@ export function restoreItem(json) {
219
221
  item.loadedAmmo = json.loadedAmmo;
220
222
  if (json.jammed)
221
223
  item.jammed = true;
224
+ if (json.bricked)
225
+ item.bricked = true;
222
226
  if (json.deckDamage)
223
227
  item.takeDeckDamage(json.deckDamage);
224
228
  if (json.droneDamage)
@@ -663,7 +667,7 @@ export function captureHubOverlay(scene, hubSeed, player, homeRoom) {
663
667
  .map(n => n.player.name)
664
668
  .filter(name => !scene.getActor(name));
665
669
  const devices = [...collectDevices(scene).entries()]
666
- .map(([name, d]) => ({ name, open: d.isOpen(), ...(d.barrierDamage > 0 ? { damage: d.barrierDamage } : {}) }));
670
+ .map(([name, d]) => ({ name, open: d.isOpen(), ...(d.barrierDamage > 0 ? { damage: d.barrierDamage } : {}), ...(d.isBricked ? { bricked: true } : {}) }));
667
671
  // THE TOMBSTONE (see ISavedHub.puzzles): always [], never read here.
668
672
  // Omitting it crashes v5.64.0's unguarded for-of on resume, and
669
673
  // saves move between builds via the cloud mirror.
@@ -824,7 +828,7 @@ export function applyHubOverlay(scene, saved, player, homeRoom) {
824
828
  for (const d of saved.devices ?? []) {
825
829
  const live = liveDevices.get(d.name);
826
830
  if (live)
827
- live.restoreState({ open: d.open, damage: d.damage });
831
+ live.restoreState({ open: d.open, damage: d.damage, bricked: d.bricked });
828
832
  }
829
833
  // A SAVE WRITTEN BEFORE DEVICES EXISTED has no `devices` key at all,
830
834
  // so every device would read shut -- while the door it holds stays
@@ -85,6 +85,10 @@ export function directConnectionBlocker(actor, room) {
85
85
  export function camerasLive(rooms, room) {
86
86
  if (!isWatched(room))
87
87
  return false;
88
+ // Looped by Control Device or bricked by a Data Spike (commands/
89
+ // disable.ts): the house sees nothing from this cluster either way.
90
+ if (room.camerasLooped || room.camerasBricked)
91
+ return false;
88
92
  if (room.hasNode)
89
93
  return !room.hostCracked;
90
94
  const hosts = Object.values(rooms).filter(r => r.hasNode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.182.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.",