@maka/maka-cli 5.236.0 → 5.238.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.236.0",
3
+ "version": "5.238.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.",
@@ -72,12 +72,12 @@ export class DisableCommand extends Command {
72
72
  // 2. A DEVICE ICON: inside the host, its WAN; from the grid, a slaved
73
73
  // device you hold a host mark for, or a loose wireless one where
74
74
  // the body stands. Mirrors hack.ts tryDeviceHack.
75
- const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name)).filter(d => d.name.toLowerCase().includes(raw)
75
+ const matching = (room, inHost = false) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name, inHost)).filter(d => d.name.toLowerCase().includes(raw)
76
76
  || (d.opensExit ?? '').toLowerCase() === raw
77
77
  || d.description.toLowerCase().includes(raw));
78
78
  const inside = actor.insideHost;
79
79
  if (inside) {
80
- const matches = matching(inside);
80
+ const matches = matching(inside, true);
81
81
  if (matches.length === 1)
82
82
  return { kind: 'device', device: matches[0], room: inside, host: inside, viaWan: false };
83
83
  if (matches.length > 1)
@@ -179,6 +179,22 @@ export class HackCommand extends BypassCommand {
179
179
  const roster = names.length > 0
180
180
  ? ` In reach: ${names.slice(0, 8).join(', ')}${names.length > 8 ? `, and ${names.length - 8} more` : ''} -- "${this.verbName} <name>" reaches one from here (Tab completes it).`
181
181
  : '';
182
+ // AND IF THEY NAMED SOMETHING THAT IS SIMPLY DARK, SAY SO
183
+ // (reporter's ruling 2026-09-17: slaved security gear comes up
184
+ // with its radio off). A player who types "hack keypad" has read
185
+ // the keypad off the room's own prose with their eyes; the grid
186
+ // genuinely has nothing by that name, and "no host, no PAN, no
187
+ // camera" is a true sentence that answers the wrong question and
188
+ // teaches nothing. This is the one moment the difference between
189
+ // HIDDEN and DARK is worth spelling out, and it is also the only
190
+ // place a decker on a fully hardwired site would otherwise never
191
+ // meet the verb that still works.
192
+ const dark = named
193
+ ? (this.actor.bodyRoom ?? this.actor.currentLocation).devices.find(d => d.rating > 0 && !d.broadcastsAro() && d.name.toLowerCase().includes(named.toLowerCase()))
194
+ : undefined;
195
+ if (dark) {
196
+ return `${dark.name} has NO RADIO -- it is wired into this place, not broadcasting, and the grid cannot see it at all (p.421). Nothing you do out here will reach it.${hint(` (p.421 takes away WIRELESS hacking, not the port: "jack out", walk to it, and "tap ${dark.name}" -- a cable beats its master's ratings and a mark on a slave is a mark on the host.)`)}`;
197
+ }
182
198
  return named
183
199
  ? `Nothing on the grid answers to "${named}" -- no host by that name, no PAN, no camera.${roster}`
184
200
  : `Thin grid out here -- no host node hangs over ${gridVicinity(this.actor).name}.${roster}`;
@@ -569,7 +585,7 @@ export class HackCommand extends BypassCommand {
569
585
  if (!named)
570
586
  return null;
571
587
  const raw = named.toLowerCase();
572
- const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name)).filter(d => d.name.toLowerCase().includes(raw)
588
+ const matching = (room, inHost = false) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name, inHost)).filter(d => d.name.toLowerCase().includes(raw)
573
589
  || (d.opensExit ?? '').toLowerCase() === raw
574
590
  || d.description.toLowerCase().includes(raw));
575
591
  // INSIDE THE HOST you are directly connected to every device on
@@ -579,7 +595,7 @@ export class HackCommand extends BypassCommand {
579
595
  let viaWan = false;
580
596
  let device;
581
597
  if (host) {
582
- const matches = matching(host);
598
+ const matches = matching(host, true);
583
599
  if (matches.length !== 1)
584
600
  return null;
585
601
  device = matches[0];
@@ -608,7 +624,14 @@ export class HackCommand extends BypassCommand {
608
624
  viaWan = true;
609
625
  const onHost = host.hostMarksBy.get(actor.name) ?? 0;
610
626
  if (onHost < 1) {
611
- return `${device.name} is on the map, but it 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(` ("${this.verbName} ${hostLabel(host)}" for a mark first; "enter" once you have one and the lock defends with its own DR ${device.rating} alone.)`)}`;
627
+ // AND THE OTHER DOOR IS A CABLE (eJANPpm7qCaCAJBJw). This
628
+ // refusal used to name one way past it -- go through the host
629
+ // -- which made the host the only road to everything hanging
630
+ // off it. p.233 carves out the exception in the same breath as
631
+ // the rule: a direct connection, and the slave "cannot use its
632
+ // master's ratings to defend itself". It costs the runner's
633
+ // body in the room, which is the whole trade.
634
+ return `${device.name} is on the map, but it 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(` ("${this.verbName} ${hostLabel(host)}" for a mark first; "enter" once you have one and the lock defends with its own DR ${device.rating} alone. Or go the other way: "jack out", walk to it, and "tap ${device.name}" -- a cable doesn't ask the network for permission, and a mark on a slave is a mark on its master.)`)}`;
612
635
  }
613
636
  }
614
637
  else {
@@ -1,12 +1,13 @@
1
1
  import { Command } from './command.js';
2
2
  import { billAction } from '../utilities/action-cost.js';
3
- import { hostLabel } from '../utilities/grid-names.js';
3
+ import { hostLabel, matchesCameraName } from '../utilities/grid-names.js';
4
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
5
5
  import { accrueOverwatch } from '../utilities/overwatch.js';
6
6
  import { MAX_MARKS } from '../utilities/marks.js';
7
7
  import { isWatched, cameraMasterHost, directConnectionBlocker } from '../utilities/surveillance.js';
8
8
  import { hint } from '../utilities/hints.js';
9
9
  import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
10
+ import { deviceDefensePool, bruteForceMatrixDv, freeMatrixPerceptionHits, overwatchFromDefense } from '../utilities/matrix-intrusion.js';
10
11
  /**
11
12
  * TAP -- THE DIRECT CONNECTION (backlog items 9 and 10, Niko, session
12
13
  * 2026-08-27T02-07-12-404Z: "check the rules on this -- shouldn't I be
@@ -44,6 +45,15 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
44
45
  * against a camera's ratings instead of a host's -- and the cost is
45
46
  * that you had to walk in and touch it.
46
47
  *
48
+ * IT IS NOT ONLY FOR CAMERAS ANY MORE (eJANPpm7qCaCAJBJw). The verb
49
+ * shipped pointed at a lens because that is what the reports asked for,
50
+ * and that left every OTHER slaved device -- the maglock, the keypad,
51
+ * the safe, the security furniture p.217 names first -- reachable only
52
+ * by cracking the host first. Backwards: Data Trails p.87-88 works this
53
+ * exact play on a DOOR, and the cable is the reason a hardened site is
54
+ * something you walk into rather than something you read off the sky.
55
+ * `tap <name>` is that, and tapDevice below is the rule.
56
+ *
47
57
  * WHERE THE AIR-GAP CLAUSE WENT. This used to say the cable's one
48
58
  * outright win was an air-gapped host, which had no door onto the grid
49
59
  * at all. There is no such host any more (hhSWzLSXECFtfoAGa): a host is
@@ -55,7 +65,7 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
55
65
  */
56
66
  export class TapCommand extends Command {
57
67
  static verb = 'tap';
58
- static description = 'Cable into a camera you are standing next to -- a DIRECT CONNECTION (p.232), run as HACK ON THE FLY (p.240). A slaved device can\'t hide behind its host\'s ratings against a cable, so the camera fights you with its own; and a mark on a slaved device is a mark on the host it answers to (p.233), which is your way in. Needs your BODY in the room and a deck in hand. Fail and you are noticed. To cable in loud instead, "force camera" is Brute Force over the same cable (p.238).';
68
+ static description = 'Cable into a device you are standing next to -- "tap" for the cameras, or "tap <keypad/maglock/safe>" for the security furniture. A DIRECT CONNECTION (p.232), run as HACK ON THE FLY (p.240). A slaved device can\'t hide behind its host\'s ratings against a cable, so it fights you with its own; and a mark on a slaved device is a mark on the host it answers to (p.233), which is your way in. Works on an icon you have never spotted and on one whose wireless is off -- a cable is not wireless (p.421). Needs your BODY in the room and a deck in hand. Fail and you are noticed. To cable into the lenses loud instead, "force camera" is Brute Force over the same cable (p.238).';
59
69
  /**
60
70
  * WHICH INTRUSION ACTION THE CABLE CARRIES. Same rule as hack.ts:
61
71
  * the verb decides, not a word inside the target name. `tap loud`
@@ -69,13 +79,38 @@ export class TapCommand extends Command {
69
79
  /** How `force camera` reaches the loud route over the same cable. */
70
80
  withMode(m) { this.modeOverride = m; return this; }
71
81
  mode() { return this.modeOverride ?? 'sleaze'; }
72
- async execute() {
82
+ async execute(args = []) {
73
83
  const actor = this.actor;
74
84
  const room = actor.currentLocation;
75
85
  const rooms = this.rooms ?? {};
76
86
  const mode = this.mode();
87
+ // THE CABLE GOES INTO WHATEVER YOU NAME (eJANPpm7qCaCAJBJw, Maka:
88
+ // "a device slaved to a host cannot be detected or interacted with
89
+ // outside the host"). The cameras were the only thing this verb
90
+ // could reach, which left the maglock, the keypad and the safe --
91
+ // the security furniture p.217 names first -- reachable ONLY by
92
+ // owning the host first. That is backwards from the book's own
93
+ // worked example: Data Trails p.87-88 runs the play on a DOOR.
94
+ const named = args.join(' ').trim();
95
+ if (named && !matchesCameraName(named)) {
96
+ const device = this.resolveDevice(room, named);
97
+ if (device)
98
+ return this.tapDevice(room, device, mode);
99
+ // NAMED SOMETHING, GOT SOMETHING ELSE IS NOT AN OPTION. This used
100
+ // to fall through to the lenses whenever the room had any, so
101
+ // `tap turnstile` in a watched room cabled the cameras and
102
+ // reported a mark on a host the player never aimed at -- a
103
+ // Complex Action spent on the wrong target with no way to tell.
104
+ // A word that names nothing here is a refusal that lists what it
105
+ // could have named.
106
+ return this.nothingNamed(room, named);
107
+ }
77
108
  if (!isWatched(room)) {
78
- return `No cameras in here to cable into.${hint(` (Your overlay tags them " Camera cluster" when there are.)`)}`;
109
+ // NOT "your overlay tags them", which stopped being true when
110
+ // slaved lenses went dark (reporter's ruling 2026-09-17): a
111
+ // hardwired cluster has no icon for the overlay to tag. The
112
+ // housings are a thing the room's prose names and your eyes find.
113
+ return `No cameras in here to cable into.${hint(` (Look for the housings -- a hardwired cluster shows no icon at all, and still takes a cable.)`)}`;
79
114
  }
80
115
  // The refusals TEACH (item 9 was a player asking whether the rules
81
116
  // allowed this at all) -- each one names what a direct connection
@@ -216,5 +251,213 @@ export class TapCommand extends Command {
216
251
  this.scene.updateStatus();
217
252
  return lines.join('\n');
218
253
  }
254
+ /**
255
+ * WHICH THING IN THIS ROOM THE PLAYER MEANT. The same shape hack.ts's
256
+ * device route uses -- name, the exit it seals, or its description --
257
+ * with two deliberate differences, and they are the whole point of
258
+ * this route:
259
+ *
260
+ * IT DOES NOT ASK hasIconFor. A silent icon is hidden ON THE GRID
261
+ * (p.217, p.235-236). Your body is standing in front of the object
262
+ * with a hand on its housing; you are not looking for it, you are
263
+ * touching it. Requiring a Matrix Perception sweep before you may
264
+ * cable into a keypad you can SEE would be the search rule applied
265
+ * to a thing that was never lost.
266
+ *
267
+ * IT DOES NOT ASK wireless. p.421's "Turning It Off" is exact about
268
+ * what that costs a device: "the items can no longer be wirelessly
269
+ * hacked." A cable is not wireless. A dark maglock is immune to
270
+ * everything on the grid and still opens to a filament -- which is
271
+ * why physical infiltration stays the answer to a hardened site
272
+ * rather than a dead end.
273
+ *
274
+ * Open devices are excluded: a thing standing open has nothing left to
275
+ * key.
276
+ */
277
+ resolveDevice(room, named) {
278
+ const raw = named.toLowerCase();
279
+ const matches = room.openableDevices()
280
+ .filter(d => d.rating > 0)
281
+ .filter(d => d.name.toLowerCase().includes(raw)
282
+ || (d.opensExit ?? '').toLowerCase() === raw
283
+ || d.description.toLowerCase().includes(raw));
284
+ return matches.length === 1 ? matches[0] : undefined;
285
+ }
286
+ /** Named something this room has no cable-end for. Says what it DOES
287
+ * have, because a refusal that lists the connectors is a refusal that
288
+ * teaches the verb. */
289
+ nothingNamed(room, named) {
290
+ const here = room.openableDevices().filter(d => d.rating > 0).map(d => d.name);
291
+ if (isWatched(room))
292
+ here.push('the cameras (bare "tap")');
293
+ return here.length > 0
294
+ ? `Nothing here called "${named}" has a data connector on it. What does: ${here.join(', ')}.`
295
+ : `Nothing in ${room.name} has a port to cable into -- no cameras, no panel, no lock with electronics in it.`;
296
+ }
297
+ /**
298
+ * A DIRECT CONNECTION TO A DEVICE (SR5 p.232-233; Data Trails p.87-88,
299
+ * "A Lesson in Host Invasion" -- which runs this exact play on a DOOR
300
+ * slaved to a host, not on a camera).
301
+ *
302
+ * THE RULE THIS BUYS, and it is the one the report has been circling:
303
+ * "If a slaved device is under attack via a direct connection, it
304
+ * cannot use its master's ratings to defend itself" (p.233). So the
305
+ * keypad that hides behind HR 3 out on the grid -- and that `hack`
306
+ * from the grid refuses outright until you hold a mark on the host --
307
+ * defends here with nothing but its own DR. That is the identical pool
308
+ * `hack` faces from INSIDE the host, because being inside a WAN and
309
+ * holding a cable are the same rule: "if you are in a host that has a
310
+ * WAN, you are considered directly connected to all devices in the
311
+ * WAN" (p.233).
312
+ *
313
+ * AND THE PAYOFF RUNS UPHILL: "if you get a mark on a slave you also
314
+ * get a mark on the master" (p.233). A filament in a maglock is a key
315
+ * to the host it answers to. That is the road INTO a host, which this
316
+ * engine offered only through a camera lens.
317
+ *
318
+ * THE PRICE IS YOUR BODY, which is what keeps it from being a
319
+ * shortcut: directConnectionBlocker wants you in the meat, in the
320
+ * room, with a deck in hand. A persona out on the grid cannot hold a
321
+ * cable.
322
+ */
323
+ async tapDevice(room, device, mode) {
324
+ const actor = this.actor;
325
+ const blocked = directConnectionBlocker(actor, room, device.name);
326
+ if (blocked)
327
+ return blocked;
328
+ const held = device.marksBy.get(actor.name) ?? 0;
329
+ if (held >= MAX_MARKS) {
330
+ return `You already hold ${MAX_MARKS} marks on ${device.name} -- it answers to you as readily as to the house.`;
331
+ }
332
+ const burned = actor.burnedAttributeRefusal(mode === 'attack' ? 'attack' : 'sleaze');
333
+ if (burned)
334
+ return burned;
335
+ // THE MASTER IS THE HOST OVER THIS DEVICE'S OWN ROOM, and nothing
336
+ // further. cameraMasterHost falls back to any host on the site
337
+ // because a camera NETWORK is a site-wide thing the seeds describe;
338
+ // a keypad's master is not, and reaching across the district for one
339
+ // would be inventing a WAN the seed never declared. No host over it
340
+ // means a loose device: the mark lands on the thing you cabled into
341
+ // and propagates nowhere, which is the honest answer.
342
+ const host = room.hasNode ? room : undefined;
343
+ const billed = billAction(this.scene, actor, 'complex', mode === 'attack' ? 'Brute Force' : 'Hack on the Fly');
344
+ if (billed)
345
+ return billed;
346
+ const silence = actor.matrixActionPenalty;
347
+ const limit = actor.matrixAttribute(mode === 'attack' ? 'attack' : 'sleaze');
348
+ const pool = mode === 'attack'
349
+ ? Math.max(1, actor.getAttackPool() + silence + actor.bonus('matrix-attack'))
350
+ : Math.max(1, actor.logic + actor.skillRating('hacking') + actor.bonus('hacking') + actor.bonus('matrix-sleaze')
351
+ + silence + actor.woundModifier - actor.sustainingPenalty);
352
+ actor.performAction('cables into a slaved device', `${room.name} -- a filament out of their deck and into ${device.name}`);
353
+ const roll = rollPool(pool, limit, { gremlins: actor.deckGremlins });
354
+ // ITS OWN RATINGS. Not wanDefensePool -- that is what a slave borrows
355
+ // when you come at it THROUGH the network (p.233), and the cable is
356
+ // the exception the same sentence carves out.
357
+ const defense = deviceDefensePool(device);
358
+ const defenseRoll = rollPool(defense.pool);
359
+ const netHits = roll.hits - defenseRoll.hits;
360
+ if (showsMechanics(this.scene, actor)) {
361
+ const who = mechanicsActorPrefix(this.scene, actor);
362
+ this.logger.meta(`${who}Direct connection, ${device.name} (${mode === 'attack' ? 'brute force' : 'sleaze'}): ${formatRoll(roll)}`);
363
+ this.logger.meta(` ${defense.label} (DR ${device.rating}) -- its OWN, not ${host ? hostLabel(host) : 'the network'}'s, p.233: ${formatRoll(defenseRoll)} -- net ${netHits >= 0 ? '+' : ''}${netHits}`);
364
+ }
365
+ this.logger.write(`${actor.name} direct-connected to ${device.name} in ${room.name} (${mode}): net ${netHits}, master=${host?.name ?? 'none'}.`);
366
+ // p.231-232: GOD bills the DEFENDER's hits, the same as every other
367
+ // illegal Attack or Sleaze action in this engine.
368
+ const bill = accrueOverwatch(this.scene, actor, overwatchFromDefense(defenseRoll.hits), `direct connection (${device.name}, ${mode})`);
369
+ const godLines = bill.lines;
370
+ if (netHits <= 0) {
371
+ if (mode === 'sleaze') {
372
+ // p.236: a blown Sleaze is NOTICED, and a slaved device that
373
+ // notices tells the host it hangs off.
374
+ actor.sneaking = false;
375
+ if (host) {
376
+ host.hostAlert = true;
377
+ this.scene.addWorldEvent(`${device.name} flags a bad handshake on its cable -- ${hostLabel(host, { capital: true })} is awake.`, { plane: 'matrix' });
378
+ this.scene.updateStatus();
379
+ return [
380
+ `The filament seats, the handshake goes wrong, and ${device.name} says so out loud.`,
381
+ ` The node it answers to knows something is on the wire.`,
382
+ ...godLines,
383
+ ].join('\n');
384
+ }
385
+ this.scene.updateStatus();
386
+ return [
387
+ `The filament seats and ${device.name} refuses the key -- whoever owns it has your icon on file now.`,
388
+ ...godLines,
389
+ ].join('\n');
390
+ }
391
+ // p.236: a failed Attack is never noticed.
392
+ this.scene.updateStatus();
393
+ return [
394
+ `You put your shoulder into it down the wire and ${device.name} just... doesn't give. Nobody upstairs noticed.`,
395
+ ...godLines,
396
+ ].join('\n');
397
+ }
398
+ const now = Math.min(MAX_MARKS, held + 1);
399
+ device.marksBy.set(actor.name, now);
400
+ // YOU CANNOT HOLD A MARK ON AN ICON YOU CANNOT PICK OUT. Every other
401
+ // route to a mark goes through seeing the thing first -- from the
402
+ // grid you have to target an icon, and targeting a silent one means
403
+ // beating it in an opposed Matrix Perception. The cable is the one
404
+ // route that does not, because it is your hand on the housing rather
405
+ // than a search. Without this the new verb left a state nothing else
406
+ // in the game can reach: a device you own and cannot see, invisible
407
+ // on the grid you just keyed it from. Banked OUTSIDE (p.246), since
408
+ // what you learned you learned off the object itself.
409
+ device.markSpotted(actor.name);
410
+ const lines = [
411
+ `{light-blue-fg}The filament finds the connector and ${device.name} stops being furniture -- it's a device, and it's talking to you.{/light-blue-fg}`,
412
+ ` {light-blue-fg}It never gets to hide behind the network: a cable doesn't ask permission, so it has to defend itself on DR ${device.rating} alone (p.233).{/light-blue-fg}`,
413
+ ` {light-blue-fg}MARK PLACED -- ${now} of ${MAX_MARKS} on ${device.name}.{/light-blue-fg}`,
414
+ ];
415
+ // A MARK ON A SLAVE IS A MARK ON THE MASTER (p.233). One, whatever
416
+ // you took off the slave -- the same rule hack.ts's device route
417
+ // applies, and the reason this verb is a way IN rather than only a
418
+ // way through a door.
419
+ if (host) {
420
+ const onHost = host.hostMarksBy.get(actor.name) ?? 0;
421
+ if (onHost < MAX_MARKS)
422
+ host.hostMarksBy.set(actor.name, onHost + 1);
423
+ const nowHost = host.hostMarksBy.get(actor.name) ?? 0;
424
+ lines.push(` {light-blue-fg}And it lands on the host too: ${device.name} answers to ${hostLabel(host)}, and anything ${hostLabel(host)} trusts to speak for it just handed you a key. [${nowHost}/${MAX_MARKS}]{/light-blue-fg}`);
425
+ lines.push(` {light-blue-fg}${nowHost >= MAX_MARKS
426
+ ? `That node thinks you belong now -- "jack in" and "hack" walks straight in.`
427
+ : `${hostLabel(host, { capital: true })} cracks easier with every key you hold${hint(` -- "jack in" and "hack", or find another slave to cable`)}.`}{/light-blue-fg}`);
428
+ }
429
+ if (mode === 'attack') {
430
+ actor.sneaking = false;
431
+ if (host)
432
+ host.hostAlert = true;
433
+ this.scene.addWorldEvent(`${actor.name} forced ${device.name} over a cable -- the housing shows it.`);
434
+ // p.238: Brute Force burns what it forces, 1 DV per two full net
435
+ // hits, resisted by Device Rating + Firewall.
436
+ const dv = bruteForceMatrixDv(netHits);
437
+ if (dv > 0) {
438
+ const resisted = Math.max(0, dv - rollPool(defense.pool).hits);
439
+ if (resisted > 0) {
440
+ device.takeBarrierDamage(resisted);
441
+ lines.push(` The forcing burns ${resisted} into its boards -- ${device.barrierSummary()}`);
442
+ if (device.isHoled)
443
+ lines.push(` It dies open. Whatever it was holding shut is not shut any more.`);
444
+ }
445
+ }
446
+ }
447
+ else {
448
+ // p.240: Hack on the Fly reads while it keys.
449
+ if (freeMatrixPerceptionHits(netHits) > 0) {
450
+ lines.push(` {light-blue-fg}And you read it while you keyed it (p.240): ${device.name} is a ${device.kind}, DR ${device.rating}${device.opensExit ? `, and it is what holds the ${device.opensExit} way shut` : ''}.{/light-blue-fg}`);
451
+ }
452
+ lines.push(` Nothing logged, nothing flagged. You were never here.`);
453
+ }
454
+ // WHAT THE MARK IS FOR. A key you cannot spend is a key that gets
455
+ // reported as broken.
456
+ lines.push(` {light-blue-fg}${device.name} takes orders from you now${hint(` -- "${device.verbs()[0]} ${device.name}"`)}.{/light-blue-fg}`);
457
+ lines.push(...godLines);
458
+ actor.noteConditionChanged();
459
+ this.scene.updateStatus();
460
+ return lines.join('\n');
461
+ }
219
462
  }
220
463
  //# sourceMappingURL=tap.js.map
@@ -1524,5 +1524,95 @@
1524
1524
  // flesh, and the books do not say whether the grid tax still applies
1525
1525
  // to an action taken down a cable. Left alone and said so rather than
1526
1526
  // guessed in either direction.
1527
- export const ENGINE_VERSION = '1.85.2';
1527
+ // 1.86.0 (2026-09-17): THE CABLE REACHES EVERY SLAVE, AND A SPOT STAYS
1528
+ // ON ITS OWN SIDE OF THE WALL (eJANPpm7qCaCAJBJw).
1529
+ // THE RULES QUESTION FIRST, because the report asked for one: a device
1530
+ // slaved to a host is NOT moved inside it. p.233's entire
1531
+ // slave-defence rule ("whenever a slaved device is called on to make a
1532
+ // defense test, it uses either its own or its master's rating") exists
1533
+ // precisely because you can come at a slave from outside, and the same
1534
+ // paragraph carves out the exception that proves it -- "if a slaved
1535
+ // device is under attack via a direct connection ... it cannot use its
1536
+ // master's ratings to defend itself". Data Trails p.87-88 runs the
1537
+ // whole worked example on a DOOR slaved to a host. Being inside the
1538
+ // host is not the only road; it is the road that costs no cable
1539
+ // ("if you are in a host that has a WAN, you are considered directly
1540
+ // connected to all devices in the WAN").
1541
+ // SO THE GAP WAS THE CABLE. `tap` could reach a camera lens and
1542
+ // nothing else, which left every maglock, keypad and safe -- the
1543
+ // security furniture p.217 names first -- reachable only by cracking
1544
+ // the host first. Exactly backwards from the book's own example.
1545
+ // `tap <device>` is the direct connection now: it rolls the device's
1546
+ // OWN Device Rating + Firewall (the identical pool `hack` faces from
1547
+ // inside the host, because both ARE direct connections), the mark it
1548
+ // buys lands on the master too (p.233), and GOD bills the defender's
1549
+ // hits (p.231-232). It reaches an icon that has never been spotted --
1550
+ // your hand is on the housing, you are not searching for it -- and one
1551
+ // whose WIRELESS IS OFF, because p.421's "Turning It Off" says only
1552
+ // that such a device "can no longer be WIRELESSLY hacked" and a
1553
+ // filament is not wireless. That is what keeps a dark site a thing you
1554
+ // walk into rather than a dead end.
1555
+ // AND THE LEAK: `enter host, glance, exit` lit up every silent icon on
1556
+ // the street. One `spottedBy` set served both vantages, and they are
1557
+ // not the same test -- inside the host a slave hides behind its BARE
1558
+ // rating (one die for a p.356 camera, p.233), outside it borrows the
1559
+ // host's Rating and Sleaze (nine on an HR 3 site). A one-die win was
1560
+ // satisfying a nine-die gate. p.246 is the rule: "the virtual space
1561
+ // inside a host is separate from the outside grid." Device.
1562
+ // spottedInsideBy and Room.camerasSpottedInsideBy keep the sides
1563
+ // apart, one way only -- an OUTSIDE spot still counts inside, because
1564
+ // it beat the harder test.
1565
+ // The other half of that report was that the inside win was invisible:
1566
+ // hostInteriorIcons listed personas, files and ice and never the WAN,
1567
+ // so a device found in there first appeared when you left. It is
1568
+ // listed inside now, where p.233 puts it.
1569
+ // 1.87.0 (2026-09-17): SLAVED SECURITY GEAR IS DARK, NOT HIDING
1570
+ // (eJANPpm7qCaCAJBJw, reporter's ruling: "If a device isn't running
1571
+ // wireless, it cannot be seen. I assume that security devices, such as
1572
+ // maglocks, cameras, sensors, etc., do not run wireless ... Let's give
1573
+ // devices a 'wireless' setting. Default security devices to have this
1574
+ // attribute of 'off'.")
1575
+ // TWO DIFFERENT ABSENCES, and the whole change is keeping them apart.
1576
+ // RUNNING SILENT (p.235-236) is an icon hiding: a sweep can beat it,
1577
+ // and a lost sweep still reports that something is there. DARK (p.421,
1578
+ // "can no longer be wirelessly hacked") is no icon at all: nothing to
1579
+ // contest, and no hint anything was ever there. Security furniture was
1580
+ // the first; it is the second now.
1581
+ // NOT A BLANKET, per the reporter's own correction: "not ALL cameras
1582
+ // need to be slaved to a host -- a mom and pop store can have a
1583
+ // wireless camera." The MASTER decides. A lock, panel or camera
1584
+ // cluster in a room with a host over it is wired into that site's
1585
+ // network and comes up dark; one with no host over it keeps its radio,
1586
+ // because the radio is the only way it reaches anyone. Seeds override
1587
+ // per device (IDeviceSeedJson.wireless) and per site
1588
+ // (Room.camerasWireless).
1589
+ // AND IT IS NOT A WALL -- the reporter's second correction, which was
1590
+ // also right: "physical contact CAN get access to the host through its
1591
+ // slaved device. Physical is NOT wireless." p.421 takes away WIRELESS
1592
+ // hacking and a filament is not wireless, so 1.86.0's `tap <device>`
1593
+ // still cables a dark maglock, still beats its own DR instead of its
1594
+ // master's (p.233), and the mark it buys is still a mark on the host.
1595
+ // From inside that host you are directly connected to the whole WAN,
1596
+ // radios or not. A dark site is not un-deckable; it is a site you have
1597
+ // to put a body into ONCE.
1598
+ // THE LEAK 1.86.0 PAPERED OVER IS NOW GONE AT THE ROOT. That version
1599
+ // answered "when I leave the host, all slaved devices are
1600
+ // automatically visible" with a second spotted-record per vantage. The
1601
+ // real answer is that there is no sweep inside a host to win: p.233
1602
+ // makes a persona in a WAN "directly connected to all devices in the
1603
+ // WAN", and a direct connection is a LINK, not a search -- the same
1604
+ // reason `tap` reaches a device nobody has ever spotted. The same
1605
+ // relationship cannot mean "you hold a link to it" for the cable and
1606
+ // "now go find it" for the host. So slaved devices are simply present
1607
+ // from inside, nothing is rolled in there, and nothing can be carried
1608
+ // back out. Device.spottedInsideBy and Room.camerasSpottedInsideBy are
1609
+ // retired.
1610
+ // WHERE A DARK DEVICE IS TAUGHT: naming one from the grid used to get
1611
+ // "no host by that name, no PAN, no camera" -- a true sentence
1612
+ // answering the wrong question, since the player read the keypad off
1613
+ // the room with their eyes. It now says the icon does not exist and
1614
+ // names the cable. The AR overlay deliberately says NOTHING: that
1615
+ // surface annotates broadcasts, which is its stated contract, and the
1616
+ // first cut of this violated it.
1617
+ export const ENGINE_VERSION = '1.87.0';
1528
1618
  //# sourceMappingURL=engine-version.js.map
@@ -11,7 +11,7 @@ export const OBJECTIVE_MAX_WEIGHT_KG = 15;
11
11
  import { Category } from '../types/shared/item-enum.js';
12
12
  import { Logger } from '../utilities/logger.js';
13
13
  import { migrateLegacySeed } from './seed-migration.js';
14
- import { Device } from '../models/device.js';
14
+ import { Device, WIRELESS_OFF_BY_DEFAULT } from '../models/device.js';
15
15
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
16
16
  import { adoptSeededIce } from '../utilities/ic-actors.js';
17
17
  import { NPC_EQUIP_CATEGORIES, outfitNpcBySlug } from '../npc-kits.js';
@@ -439,6 +439,20 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
439
439
  structure: d.structure,
440
440
  armor: d.armor,
441
441
  footprint: d.footprint,
442
+ // THE RADIO, AND WHAT DECIDES IT (reporter's ruling
443
+ // 2026-09-17, eJANPpm7qCaCAJBJw). A security device wired into
444
+ // a site's network is DARK -- no icon on the grid at all, not
445
+ // a hidden one. A standalone one keeps its radio: "not ALL
446
+ // cameras need to be slaved to a host -- a mom and pop store
447
+ // can have a wireless camera", and the same is true of the
448
+ // maglock on a corner shop's back door.
449
+ //
450
+ // SLAVED means a host stands over its own room, which is the
451
+ // same master tap.ts cables through to and the same WAN
452
+ // hack.ts reaches from inside. One definition, three readers.
453
+ // The seed still overrides either way.
454
+ wireless: d.wireless ?? !(roomJson.hasNode === true && WIRELESS_OFF_BY_DEFAULT.has(kind)),
455
+ silent: d.silent,
442
456
  });
443
457
  room.devices.push(device);
444
458
  // A seed may declare a device ALREADY OPEN. Applied before the
@@ -25,6 +25,33 @@ export const DEFAULT_DEVICE_RATING = {
25
25
  * has no wireless mode to hide in.
26
26
  */
27
27
  export const SILENT_BY_DEFAULT = new Set(['lock', 'panel']);
28
+ /**
29
+ * WHICH KINDS ARE BORN WITH THE RADIO OFF (reporter's ruling 2026-09-17,
30
+ * eJANPpm7qCaCAJBJw: "If a device isn't running wireless, it cannot be
31
+ * seen. I assume that security devices, such as maglocks, cameras,
32
+ * sensors, etc., do not run wireless ... Let's give devices a 'wireless'
33
+ * setting. Default security devices to have this attribute of 'off'").
34
+ *
35
+ * The setting already existed and every seed left it on. This makes the
36
+ * security furniture dark by default instead, which is a stronger claim
37
+ * than running silent and a different one: silent is an icon HIDING
38
+ * (p.235-236, beatable by Matrix Perception), dark is no icon at all
39
+ * (p.421, "can no longer be wirelessly hacked" -- there is nothing to
40
+ * roll against).
41
+ *
42
+ * IT IS NOT A WALL, and that is the whole reason the ruling is
43
+ * affordable. p.421 says WIRELESSLY. A cable is not wireless: `tap
44
+ * <device>` still cables into a dark maglock in the flesh, still beats
45
+ * its own DR rather than its master's (p.233), and the mark it buys is
46
+ * still a mark on the host (p.233, Data Trails p.87-88). From inside
47
+ * that host you are then directly connected to the whole WAN, radios or
48
+ * not. So a dark site is not un-deckable -- it is a site you have to
49
+ * put a body into once, after which everything works.
50
+ *
51
+ * `silent` stays set on these kinds too, for the seed that turns a
52
+ * radio back ON: a device broadcasting again should still be hiding.
53
+ */
54
+ export const WIRELESS_OFF_BY_DEFAULT = new Set(['lock', 'panel']);
28
55
  /**
29
56
  * THE BOOK'S BARRIER TIERS (p.197 Barrier Ratings, RAG-checked 2026-09-03):
30
57
  * Structure / Armor. Fragile is standard glass; Reinforced is a security
@@ -164,16 +191,51 @@ export class Device {
164
191
  * not go missing again between looks.
165
192
  */
166
193
  spottedBy = new Set();
167
- /** Is this icon on the grid for this viewer? Wireless and rated (the
168
- * old broadcastsAro question), and either loud or already spotted. */
169
- hasIconFor(viewerName) {
194
+ /**
195
+ * IS THIS ICON THERE FOR THIS VIEWER, from where they are standing?
196
+ *
197
+ * `inside` means the viewer's persona is inside the host this device
198
+ * is slaved to, and it is the whole of the difference (Maka, 2026-09-17:
199
+ * "physical contact CAN get access to the host through its slaved
200
+ * device. Physical is NOT wireless").
201
+ *
202
+ * INSIDE THE WAN THERE IS NOTHING TO FIND. p.233: "if you are in a
203
+ * host that has a WAN, you are considered directly connected to all
204
+ * devices in the WAN." A direct connection is a LINK, not a search --
205
+ * which is exactly why `tap` reaches a device nobody has ever spotted.
206
+ * The same relationship cannot mean "you hold a link to it" for the
207
+ * cable and "now go find it" for the host. So from in here a slaved
208
+ * device is simply present, radio or no radio: the WAN link is the
209
+ * WIRE, and the airwaves have nothing to do with it.
210
+ *
211
+ * OUTSIDE IT IS THE AIRWAVES OR NOTHING. No wireless, no icon (p.421:
212
+ * a wireless-off device "can no longer be wirelessly hacked"). With a
213
+ * radio it still has to be picked out of the noise if it is running
214
+ * silent (p.235-236).
215
+ *
216
+ * THIS IS ALSO WHAT KILLS THE LEAK the reporter found -- "when I leave
217
+ * the host, all slaved devices are automatically visible" -- and kills
218
+ * it by construction rather than by bookkeeping. 1.86.0 fixed it with
219
+ * a second `spottedInsideBy` record so a cheap sweep inside could not
220
+ * satisfy the dear gate outside. There is no sweep inside any more, so
221
+ * there is nothing to bank and no second record to keep honest.
222
+ */
223
+ hasIconFor(viewerName, inside = false) {
224
+ if (inside)
225
+ return this.rating > 0;
170
226
  if (!this.broadcastsAro())
171
227
  return false;
172
228
  return !this.silent || this.spottedBy.has(viewerName);
173
229
  }
174
- /** Hidden from this viewer, and therefore worth rolling against. */
175
- isHiddenFrom(viewerName) {
176
- return this.broadcastsAro() && this.silent && !this.spottedBy.has(viewerName);
230
+ /** Hidden from this viewer, and therefore worth rolling against. Never
231
+ * from inside its own host: see hasIconFor. */
232
+ isHiddenFrom(viewerName, inside = false) {
233
+ return !inside && this.broadcastsAro() && this.silent && !this.spottedBy.has(viewerName);
234
+ }
235
+ /** Record a sweep this viewer won -- or a cable they put into it, which
236
+ * teaches the same fact the harder way. */
237
+ markSpotted(viewerName) {
238
+ this.spottedBy.add(viewerName);
177
239
  }
178
240
  /**
179
241
  * WHAT IT IS MADE OF (p.197-198): Structure is the condition monitor
@@ -81,6 +81,22 @@ export class Room extends AbstractRoom {
81
81
  * of the grid.
82
82
  */
83
83
  camerasSpottedBy = new Set();
84
+ /**
85
+ * DOES THIS CLUSTER RUN A RADIO AT ALL (reporter's ruling 2026-09-17,
86
+ * eJANPpm7qCaCAJBJw)? Unset takes the derived answer in
87
+ * surveillance.ts camerasBroadcast: slaved to a host means DARK,
88
+ * standalone means broadcasting -- "not ALL cameras need to be slaved
89
+ * to a host, a mom and pop store can have a wireless camera". Set it
90
+ * to force either way per site.
91
+ */
92
+ camerasWireless;
93
+ /** Has this viewer pinned the cluster's icon? Inside the host these
94
+ * lenses are slaved to, there is nothing to pin: p.233 makes a
95
+ * persona in the WAN directly connected to everything on it, which is
96
+ * a LINK and not a search. Mirrors Device.hasIconFor. */
97
+ camerasSpottedFor(viewerName, inside = false) {
98
+ return inside || this.camerasSpottedBy.has(viewerName);
99
+ }
84
100
  /**
85
101
  * WHAT THE LENSES RATE (eJANPpm7qCaCAJBJw, third rejection: "the
86
102
  * camera cluster doesn't have a DR"), and the reporter's ruling that
@@ -1,5 +1,5 @@
1
1
  import { isLinked, CALL_COLOR } from './comm-style.js';
2
- import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
2
+ import { isWatched, camerasLive, canSnoopFeeds, camerasBroadcast } from './surveillance.js';
3
3
  import { Category } from '../types/shared/item-enum.js';
4
4
  import { hint } from './hints.js';
5
5
  import { cameraDrTag, drTagFor } from './grid-view.js';
@@ -143,7 +143,12 @@ export function aroTags(scene, actor, room) {
143
143
  // the host, so it shows only to whoever owns the feeds.
144
144
  // Same gate as the grid: this overlay draws ICONS, and a camera icon
145
145
  // is the first thing p.217 lists as deliberately hidden until spotted.
146
- if (isWatched(room) && room.camerasSpottedBy.has(actor.name)) {
146
+ // A DARK CLUSTER IS CORRECTLY ABSENT, for the same reason a dark
147
+ // device is (reporter's ruling 2026-09-17): lenses wired into a site's
148
+ // host broadcast nothing, and this overlay annotates broadcasts. The
149
+ // housings are still there to be seen and still take a cable -- that
150
+ // is the meat layer's business, not this one's.
151
+ if (isWatched(room) && camerasBroadcast(scene.getRooms(), room) && room.camerasSpottedBy.has(actor.name)) {
147
152
  const rooms = scene.getRooms();
148
153
  // The verb rides the tag: standing in the room IS the requirement
149
154
  // for a direct connection (commands/tap.ts), so this is the one
@@ -174,9 +179,29 @@ export function aroTags(scene, actor, room) {
174
179
  // (p.217, p.235-236). The OBJECT is untouched: the maglock is still
175
180
  // screwed to the door, still described by a meat-world `look`, and
176
181
  // still picked, breached, shot and keyed exactly as before.
182
+ // A DARK DEVICE IS CORRECTLY ABSENT FROM THIS LIST (reporter's
183
+ // ruling 2026-09-17: slaved security gear comes up with its radio
184
+ // off). This overlay annotates BROADCASTS -- see the contract at the
185
+ // top of the file -- and a device with no radio has nothing to
186
+ // annotate, exactly like a silent PAN or an offline server.
187
+ //
188
+ // The first cut of that ruling printed a "NO RADIO" line here on the
189
+ // grounds that a body has eyes. It does, but this is not the eyes:
190
+ // the object is named by the room's own prose and worked by the meat
191
+ // verbs, and commands/bypass-command.ts is where a body is told what
192
+ // its hands and its cable can reach. Two surfaces, one contract each.
177
193
  if (!device.hasIconFor(actor.name))
178
194
  continue;
179
- tags.push(`▤ ${device.name} -- wireless, ${drTagFor(device, actor.name)}${device.isOpen() ? ', standing open' : `.${hint(` ("${device.verbs()[0]} ${device.name}")`)}`}`);
195
+ // THE CABLE IS THE OTHER VERB (eJANPpm7qCaCAJBJw). The overlay
196
+ // taught the physical one and nothing else, so a decker standing in
197
+ // front of a slaved keypad had no way to learn that the filament in
198
+ // their deck beats the host's ratings (p.233). Only offered when
199
+ // there is a deck to plug in -- a hint naming gear you do not have
200
+ // is noise.
201
+ const cable = device.isOpen() || !(actor.getCyberdeck() || actor.isTechnomancer())
202
+ ? ''
203
+ : hint(` ("tap ${device.name}" cables in -- it can't hide behind its host against a cable)`);
204
+ tags.push(`▤ ${device.name} -- wireless, ${drTagFor(device, actor.name)}${device.isOpen() ? ', standing open' : `.${hint(` ("${device.verbs()[0]} ${device.name}")`)}${cable}`}`);
180
205
  }
181
206
  // LOOSE DEVICES lying in plain sight. A wireless deck on a table
182
207
  // announces itself; one in a drawer you never opened does not.
@@ -4,7 +4,7 @@ import { hostsInReach, hostLine, roomsInReach, gridVicinity } from './grid-reach
4
4
  import { NPC } from '../models/npc.js';
5
5
  import { hostLabel, hostPurposeFor } from './grid-names.js';
6
6
  import { isLinked } from './comm-style.js';
7
- import { isWatched, camerasLive, canSnoopFeeds, cameraMasterHost } from './surveillance.js';
7
+ import { isWatched, camerasLive, canSnoopFeeds, cameraMasterHost, camerasBroadcast } from './surveillance.js';
8
8
  /**
9
9
  * The grid's icon inventory for one room -- everything a persona can
10
10
  * ACT on, each with its verb (player rulings 2026-08-23/24: "it's hard
@@ -337,7 +337,7 @@ export function gridIcons(scene, actor, room) {
337
337
  // still described in the meat, and a body still "tap"s one in the
338
338
  // flesh -- it is the ICON that takes a Matrix Perception to pick out
339
339
  // of the noise (utilities/silent-icons.ts).
340
- if (isWatched(room) && room.camerasSpottedBy.has(actor.name)) {
340
+ if (isWatched(room) && camerasBroadcast(scene.getRooms(), room) && room.camerasSpottedBy.has(actor.name)) {
341
341
  const rooms = scene.getRooms();
342
342
  if (!camerasLive(rooms, room)) {
343
343
  icons.push(`◎ Camera cluster ${cameraDr(room, actor.name)}-- looped on empty hallways. Security sees NOTHING here.${canSnoopFeeds(rooms, room, actor) ? ` ("snoop" rides the feeds you own)` : ''}`);
@@ -480,6 +480,29 @@ function hostInteriorIcons(scene, actor, room) {
480
480
  ? `◇ ${item.name} [data] -- unsealed, sitting open in the host's archive. ("take" it)`
481
481
  : `◇ ${item.name} [data] -- SEALED in the host's archive. ("hack" the host to break the seal)`);
482
482
  }
483
+ // THE WAN, WHICH IS WHAT YOU CAME IN FOR (p.233: "if you are in a host
484
+ // that has a WAN, you are considered directly connected to all devices
485
+ // in the WAN"; eJANPpm7qCaCAJBJw, Maka: "when I leave the host, all
486
+ // slaved devices are automatically visible").
487
+ //
488
+ // This list did not exist, and its absence is half of that report. A
489
+ // sweep inside the host rolls against the slave's BARE rating -- one
490
+ // die for a p.356 camera, because you are directly connected and it
491
+ // "cannot use its master's ratings" -- and then nothing in here
492
+ // printed what you had just found. The win was invisible until the
493
+ // persona stepped back out onto the grid, where the icon appeared for
494
+ // free against a test it had never beaten. Device.spottedInsideBy now
495
+ // keeps the two sides of that wall apart (p.246); this is the side
496
+ // that had nothing to show.
497
+ //
498
+ // Same gate as the outside list, read from in here: an icon is on this
499
+ // list once THIS viewer has pinned it, and `hack`/`disable` target
500
+ // exactly the same set.
501
+ for (const device of room.devices) {
502
+ if (!device.hasIconFor(actor.name, true))
503
+ continue;
504
+ icons.push(deviceIconLine(device, actor));
505
+ }
483
506
  // THE ICE IS IN THE ROOM WITH YOU, AND THE ROOM USED TO DENY IT.
484
507
  //
485
508
  // A regression this file shipped: hostInteriorIcons listed personas
@@ -1,6 +1,6 @@
1
1
  import { rollPool, formatRoll } from './dice.js';
2
2
  import { deviceHidePool, wanHidePool } from './matrix-intrusion.js';
3
- import { isWatched, cameraMasterHost } from './surveillance.js';
3
+ import { isWatched, cameraMasterHost, camerasBroadcast } from './surveillance.js';
4
4
  import { gridActionModifier, gridNote } from './grids.js';
5
5
  function hiddenDevice(device, master) {
6
6
  // p.235-236: the hider rolls LOGIC + SLEAZE. Not Firewall -- that
@@ -13,7 +13,7 @@ function hiddenDevice(device, master) {
13
13
  return {
14
14
  name: device.name,
15
15
  hide: { pool: hide.pool, label: hide.label },
16
- reveal: (viewer) => { device.spottedBy.add(viewer); },
16
+ reveal: (viewer) => { device.markSpotted(viewer); },
17
17
  };
18
18
  }
19
19
  function hiddenPersona(actor) {
@@ -34,13 +34,23 @@ function hiddenPersona(actor) {
34
34
  * body does.
35
35
  */
36
36
  export function hiddenIconsIn(scene, room, viewer) {
37
- // A device in a room with a host is slaved to it (p.233), and from
38
- // the grid it borrows the master's ratings. Inside the host you are
39
- // directly connected and it stands alone -- deviceHidePool.
40
- const deviceMaster = viewer.hostInside ? undefined : room.host;
37
+ // A device in a room with a host is slaved to it (p.233) and borrows
38
+ // the master's ratings to stay hidden.
39
+ //
40
+ // NOTHING IS HIDDEN FROM INSIDE THE HOST. p.233: a persona in a WAN is
41
+ // "considered directly connected to all devices in the WAN", and a
42
+ // direct connection is a LINK, not a search -- the same reason `tap`
43
+ // reaches a device nobody has ever spotted. isHiddenFrom answers false
44
+ // for every slave in here, so this list comes back empty of devices
45
+ // and the sweep in there is only ever about PEOPLE.
46
+ //
47
+ // That is also what retired the `spottedInsideBy` record 1.86.0 added:
48
+ // a sweep that cannot happen has no result to keep on the right side
49
+ // of the wall.
50
+ const inside = !!viewer.hostInside;
41
51
  const out = room.devices
42
- .filter(d => d.isHiddenFrom(viewer.name))
43
- .map(d => hiddenDevice(d, deviceMaster));
52
+ .filter(d => d.isHiddenFrom(viewer.name, inside))
53
+ .map(d => hiddenDevice(d, room.host));
44
54
  // THE CAMERAS, which p.217 names FIRST. They are not a Device -- the
45
55
  // cluster is derived from the room (surveillance.ts isWatched) -- so
46
56
  // the rating rides the ROOM (Room.cameraRating), seeded per site.
@@ -58,7 +68,13 @@ export function hiddenIconsIn(scene, room, viewer) {
58
68
  // could not have reconciled them because two of the three were never
59
69
  // printed. One seeded number feeds all three now, so what the player
60
70
  // reads is what it rolls (reporter's ruling: per-site, not global).
61
- if (isWatched(room) && !room.camerasSpottedBy.has(viewer.name)) {
71
+ // A DARK CLUSTER IS NOT HIDING, IT IS ABSENT (reporter's ruling
72
+ // 2026-09-17). Lenses wired into a site's host broadcast nothing at
73
+ // all, so there is no icon to contest and not even a "something is
74
+ // running silent here" -- p.421 leaves a wireless-off device with
75
+ // nothing to find. camerasBroadcast is the one predicate; the grid
76
+ // view reads the same one.
77
+ if (isWatched(room) && camerasBroadcast(scene?.getRooms() ?? {}, room) && !room.camerasSpottedFor(viewer.name, inside)) {
62
78
  // THE THIRD CUT, and the reporter found it by doing the arithmetic
63
79
  // out loud: "Device Rating + Firewall on the Camera is 2d6? so, the
64
80
  // firewall of the host is a 1?" No -- Firewall was never the right
@@ -70,12 +86,14 @@ export function hiddenIconsIn(scene, room, viewer) {
70
86
  // stray one is easy -- one rule, not a balance knob (reporter's
71
87
  // ruling 2026-09-16).
72
88
  const masterRoom = scene ? cameraMasterHost(scene.getRooms(), room) : undefined;
73
- const master = viewer.hostInside ? undefined : masterRoom?.host;
89
+ const master = inside ? undefined : masterRoom?.host;
74
90
  const lens = { rating: room.cameraRating };
75
91
  const hide = master ? wanHidePool(lens, master) : deviceHidePool(lens);
76
92
  out.push({
77
93
  name: 'Camera cluster',
78
94
  hide: { pool: hide.pool, label: hide.label },
95
+ // Banked on the side of the wall it was won (p.246), like the
96
+ // devices above.
79
97
  reveal: (v) => { room.camerasSpottedBy.add(v); },
80
98
  });
81
99
  }
@@ -46,6 +46,38 @@ export function cameraMasterHost(rooms, room) {
46
46
  // site network -- whichever host you own, you own these feeds.
47
47
  return Object.values(rooms).find(r => r.hasNode);
48
48
  }
49
+ /**
50
+ * DO THESE LENSES BROADCAST AT ALL (reporter's ruling 2026-09-17,
51
+ * eJANPpm7qCaCAJBJw)?
52
+ *
53
+ * The ruling was "default security devices to have this attribute of
54
+ * 'off'", and then the correction that keeps it from being a blanket:
55
+ * "not ALL cameras need to be slaved to a host -- a mom and pop store
56
+ * can have a wireless camera."
57
+ *
58
+ * So the master decides, and it is the same test cameraMasterHost
59
+ * already answers. A cluster wired into a site's network is part of the
60
+ * security plant and comes up DARK -- no icon on the grid, nothing to
61
+ * sweep for, not even a "something is running silent here". A cluster
62
+ * with no host anywhere is the corner shop's lens on a cheap radio, and
63
+ * it keeps broadcasting, because the radio is the only way it reaches
64
+ * the owner's commlink at all.
65
+ *
66
+ * `Room.camerasWireless` overrides in either direction, for the corp
67
+ * floor that wants a visible decoy or the shop with a hardwired DVR.
68
+ *
69
+ * NONE OF THIS TOUCHES THE LENS. It is still bolted to the wall, still
70
+ * seen by eyes, still shot, still looped once you own the host, and
71
+ * still cabled into by `tap` in the flesh -- p.421 takes away WIRELESS
72
+ * hacking and a filament is not wireless.
73
+ */
74
+ export function camerasBroadcast(rooms, room) {
75
+ if (!isWatched(room))
76
+ return false;
77
+ if (room.camerasWireless !== undefined)
78
+ return room.camerasWireless;
79
+ return !cameraMasterHost(rooms, room);
80
+ }
49
81
  /**
50
82
  * CAN THIS ACTOR MAKE A DIRECT CONNECTION to the cameras in the room
51
83
  * they are standing in (canon p.232 "Direct Connections")? A direct
@@ -60,9 +92,16 @@ export function cameraMasterHost(rooms, room) {
60
92
  * the rules allowed this at all, so a refusal that teaches the rule is
61
93
  * worth more than a refusal that just says no.
62
94
  */
63
- export function directConnectionBlocker(actor, room) {
95
+ export function directConnectionBlocker(actor, room, target) {
96
+ // WHAT THE CABLE GOES INTO. The refusals name it, because a player who
97
+ // typed `tap keypad` and is told about "the lens" has been answered
98
+ // about a different object (eJANPpm7qCaCAJBJw). Cameras stay the
99
+ // default: this function was written for them and every existing
100
+ // caller means them.
101
+ const thing = target ?? 'the lens';
102
+ const its = target ? target : 'its cameras';
64
103
  if (actor.plane === 'matrix') {
65
- return `Your persona is out on the grid -- and a direct connection is a CABLE, not a route. You cannot plug a cable in from inside the Matrix (p.232): it takes your body, standing next to the lens, with a hand free. "jack out", walk to ${room.name}, and tap it in the flesh.`;
104
+ return `Your persona is out on the grid -- and a direct connection is a CABLE, not a route. You cannot plug a cable in from inside the Matrix (p.232): it takes your body, standing next to ${thing}, with a hand free. "jack out", walk to ${room.name}, and tap it in the flesh.`;
66
105
  }
67
106
  if (actor.plane === 'astral') {
68
107
  return `A spirit has no hands and no data connector. The Matrix doesn't answer to the astral.`;
@@ -71,13 +110,13 @@ export function directConnectionBlocker(actor, room) {
71
110
  return `Not from here -- a direct connection takes your own body, and yours is somewhere else.`;
72
111
  }
73
112
  if (actor.currentLocation !== room) {
74
- return `You'd have to be standing in ${room.name} to get a cable into its cameras.`;
113
+ return `You'd have to be standing in ${room.name} to get a cable into ${its}.`;
75
114
  }
76
115
  // A cyberdeck (or a datajack, which a technomancer is assumed to run
77
116
  // on) carries the microfilament cable; a bare commlink has the port
78
117
  // but nothing to hack WITH.
79
118
  if (!actor.getCyberdeck() && !actor.isTechnomancer()) {
80
- return `You have no deck to plug in. A commlink can talk to a camera; it can't crack one -- that takes a cyberdeck in hand.`;
119
+ return `You have no deck to plug in. A commlink can talk to ${target ? thing : 'a camera'}; it can't crack ${target ? 'it' : 'one'} -- that takes a cyberdeck in hand.`;
81
120
  }
82
121
  return undefined;
83
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.236.0",
3
+ "version": "5.238.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.",