@maka/maka-cli 5.227.0 → 5.229.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.227.0",
3
+ "version": "5.229.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.",
@@ -134,7 +134,20 @@ export class DisableCommand extends Command {
134
134
  return t.viaWan && t.host?.host ? wanDefensePool(t.device, t.host.host) : deviceDefensePool(t.device);
135
135
  }
136
136
  if (t.kind === 'camera') {
137
- return t.master?.host ? hostDefensePool(t.master.host) : { label: 'Device Rating + Firewall', pool: 4 };
137
+ // THE LENSES RATE WHAT THE SITE SAYS THEY RATE (Room.cameraRating,
138
+ // eJANPpm7qCaCAJBJw). This used to borrow the host's whole pool
139
+ // while silent-icons.ts hid the same cluster behind a hard-coded 4
140
+ // and the icon line printed no rating at all -- three answers to
141
+ // one question, two of them invisible. A slaved camera still
142
+ // borrows its master where the master is HIGHER (p.233, the same
143
+ // rule wanDefensePool applies to a slaved device), so a corp host
144
+ // still hardens its own cameras; what changed is that the floor is
145
+ // the seeded rating rather than a constant nobody could read.
146
+ const own = { label: 'Device Rating + Firewall', pool: Math.max(0, t.room.cameraRating * 2) };
147
+ if (!t.master?.host)
148
+ return own;
149
+ const viaHost = hostDefensePool(t.master.host);
150
+ return viaHost.pool >= own.pool ? viaHost : own;
138
151
  }
139
152
  return { label: 'Intuition + Firewall', pool: t.owner.getPanDefensePool('sleaze') };
140
153
  }
@@ -333,7 +333,17 @@ export class LookCommand extends Command {
333
333
  // in the icon block below rather than one look later.
334
334
  const swept = spotHiddenIcons(this.scene, this.actor, this.actor.insideHost === room ? room : gridVicinity(this.actor));
335
335
  const lines = [gridSculpt(this.actor, room)];
336
- lines.push(gridIconBlock(this.scene, this.actor, room, this.rooms));
336
+ // THE SWEEP IS REPORTED BEFORE THE LIST IT CHANGED
337
+ // (eJANPpm7qCaCAJBJw, third rejection: "I still see the Service
338
+ // Keypad and Camera cluster").
339
+ //
340
+ // The sweep runs first so anything it wins appears in this look
341
+ // rather than one look later -- that part was right. But its
342
+ // SENTENCE was pushed after the icon block, so the player read a
343
+ // newly-revealed keypad with nothing above it saying why, and the
344
+ // "you pick an icon out of the noise" line arrived two blocks down.
345
+ // Revealing something and explaining it in that order reads exactly
346
+ // like never having hidden it, which is what was reported.
337
347
  if (swept) {
338
348
  lines.push(spotLine(swept));
339
349
  if (showsMechanics(this.scene, this.actor)) {
@@ -342,6 +352,7 @@ export class LookCommand extends Command {
342
352
  }
343
353
  }
344
354
  }
355
+ lines.push(gridIconBlock(this.scene, this.actor, room, this.rooms, swept?.found));
345
356
  // THE COACHING HAS TO MATCH THE ROOM YOU ARE IN. Inside a host,
346
357
  // `go` is refused (go.ts checkHostBoundary) and `map` draws the
347
358
  // outside grid you cannot reach -- so offering both is the same
@@ -1,19 +1,99 @@
1
1
  import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
+ import { Player } from '../models/player.js';
3
4
  import { Category } from '../types/shared/item-enum.js';
5
+ import { billAction } from '../utilities/action-cost.js';
4
6
  /**
5
7
  * The PAN dashboard: your personal area network at a glance -- master
6
8
  * device and its firewall, every slaved device with what it grants while
7
9
  * loud, and the silent-vs-loud tradeoff spelled out. This is the screen
8
10
  * you check before deciding whether the AR bonus is worth being hackable.
11
+ *
12
+ * AND THE SWITCH (CgJ6uTfEvTd8JXdDN): "pan off <device>" drops one
13
+ * device off the network and "pan on <device>" puts it back. The
14
+ * reporter asked for `pan link <device>` and `pan link <device> on|off`;
15
+ * `link`/`unlink` are accepted as spellings of the same two verbs, but
16
+ * membership itself is DERIVED rather than hand-built, which is the
17
+ * reporter's own ruling: "most equipment is wireless, so a lot can just
18
+ * be that easy." Nobody has to assemble their network before it works.
9
19
  */
10
20
  export class PanCommand extends Command {
11
21
  static verb = 'pan';
12
- static description = 'Your personal area network: master device, slaved gear, wireless bonuses, and what running silent costs and protects.';
13
- async execute(_args = []) {
22
+ static description = 'Your personal area network: master device, slaved gear, wireless bonuses, and what running silent costs and protects. "pan off <device>" drops one off the net.';
23
+ async execute(args = []) {
24
+ const [head, ...rest] = args;
25
+ const verb = (head ?? '').toLowerCase();
26
+ // "pan link <device> off" (the reporter's spelling) and "pan off
27
+ // <device>" mean the same thing. A trailing on/off wins over the
28
+ // leading verb, so `pan link X off` unlinks rather than linking.
29
+ if (verb === 'on' || verb === 'off' || verb === 'link' || verb === 'unlink') {
30
+ const tail = (rest[rest.length - 1] ?? '').toLowerCase();
31
+ const trailing = tail === 'on' || tail === 'off' ? tail : undefined;
32
+ const nameParts = trailing ? rest.slice(0, -1) : rest;
33
+ const wanted = trailing ?? (verb === 'on' || verb === 'link' ? 'on' : 'off');
34
+ return this.switchDevice(nameParts.join(' ').trim(), wanted === 'on');
35
+ }
36
+ return this.dashboard();
37
+ }
38
+ /** One device on or off the network (p.233 -- slaving is a choice). */
39
+ switchDevice(name, on) {
40
+ const a = this.actor;
41
+ if (!name)
42
+ return `${on ? 'Link' : 'Unlink'} what? Try "pan ${on ? 'on' : 'off'} <device>".`;
43
+ const candidates = [
44
+ a.equipment.head?.commlink,
45
+ a.equipment.rightHand?.weapon,
46
+ a.equipment.leftHand?.weapon,
47
+ ...a.inventory.getAllItems(),
48
+ ].filter((i) => i != null);
49
+ const needle = name.toLowerCase();
50
+ const item = candidates.find(i => i.name.toLowerCase() === needle)
51
+ ?? candidates.find(i => i.name.toLowerCase().includes(needle));
52
+ if (!item)
53
+ return `You are not carrying "${name}".`;
54
+ if (!Player.canJoinPan(item)) {
55
+ // p.233 is explicit that only DEVICES can be in a PAN, so this is
56
+ // a refusal with a reason rather than a silent no-op.
57
+ return `${item.name} is not a device -- only devices can be slaved to a PAN (p.233). Nothing to switch.`;
58
+ }
59
+ if (item.wirelessOn === on) {
60
+ return `${item.name} is already wireless ${on ? 'ON' : 'OFF'}.`;
61
+ }
62
+ // p.421, "Turning It Off": "Toggling an individual device's wireless
63
+ // functionality off is a Free Action." Priced where the book prices
64
+ // it -- which also means it is free OUT of combat, because
65
+ // billAction only charges inside an encounter.
66
+ const bill = billAction(this.scene, a, 'free', `Wireless ${on ? 'On' : 'Off'}`);
67
+ if (bill)
68
+ return bill;
69
+ item.wirelessOn = on;
70
+ if (!on) {
71
+ return [
72
+ `${item.name} drops off the network -- wireless OFF.`,
73
+ `It broadcasts nothing now: no bonus, no icon, nothing to hack. It also stops borrowing your master's ratings to defend itself (p.233), which is only a loss if anyone was looking.`,
74
+ ].join('\n');
75
+ }
76
+ const over = a.panOverflow();
77
+ const lines = [`${item.name} joins the network -- wireless ON.`];
78
+ if (over > 0) {
79
+ // Canon ceiling, said out loud rather than truncating in silence.
80
+ lines.push(`Your master handles ${a.panCapacity()} slaved devices (Device Rating x 3, p.233) and you are carrying ${over} too many. The overflow is not on the net -- switch something off.`);
81
+ }
82
+ return lines.join('\n');
83
+ }
84
+ dashboard() {
14
85
  const a = this.actor;
15
86
  const master = a.getPanMaster();
16
87
  if (!master) {
88
+ const off = a.inventory.getAllItems().filter(i => !i.wirelessOn && Player.canJoinPan(i));
89
+ if (off.length > 0) {
90
+ // The honest reason, because "no PAN" reads like a bug when you
91
+ // are holding a commlink you switched off yourself.
92
+ return [
93
+ `No PAN -- everything that could master one is switched off.`,
94
+ ...off.map(i => ` • ${i.name} -- wireless OFF${hint(` ("pan on ${i.name}")`)}`),
95
+ ].join('\n');
96
+ }
17
97
  return `No devices, no PAN. You are unhackable and unreachable -- the monk's build.`;
18
98
  }
19
99
  const state = a.runningSilent
@@ -21,11 +101,12 @@ export class PanCommand extends Command {
21
101
  : a.arOffline
22
102
  ? `{red-fg}LOUD, AR CUT{/red-fg} -- someone hacked your overlay dark ("reboot")`
23
103
  : `{green-fg}LOUD{/green-fg} -- full bonuses, and hackable`;
104
+ const slaves = a.panSlaves();
24
105
  const lines = [
25
106
  `PAN: ${a.name.toUpperCase()}`,
26
107
  '',
27
108
  `Broadcast: ${state}`,
28
- `Master: ${master.name}`,
109
+ `Master: ${master.name} [DR ${master.deviceRating}] -- holds ${slaves.length}/${a.panCapacity()} slaved devices (DR x 3, p.233)`,
29
110
  // TWO DEFENCES, BECAUSE THERE ARE TWO (p.240 / p.238). What the
30
111
  // intruder is DOING picks the attribute, so a quiet crack and a
31
112
  // smash meet different pools -- for most characters, different
@@ -42,24 +123,26 @@ export class PanCommand extends Command {
42
123
  '',
43
124
  'Slaved devices:',
44
125
  ];
45
- const link = a.equipment.head?.commlink;
46
- if (link) {
47
- const bonus = a.runningSilent || a.arOffline ? 'inactive' : `+${link.qualityBonus} initiative (AR overlay)`;
48
- lines.push(` • ${link.name} (commlink) -- calls; ${bonus}`);
126
+ if (slaves.length === 0) {
127
+ lines.push(' (none -- the master is the whole network)');
49
128
  }
50
- const deck = a.getAnyCyberdeck();
51
- if (deck) {
52
- lines.push(` • ${deck.name} (cyberdeck) -- Matrix access; ${deck.deckSummary()}${deck.isBricked ? ' BRICKED (rest to repair)' : ''}`);
129
+ for (const item of slaves) {
130
+ lines.push(` • ${this.slaveLine(item)}`);
53
131
  }
54
- const gun = a.equipment.rightHand?.weapon ?? a.equipment.leftHand?.weapon;
55
- // Only a SMARTGUN is on the PAN (p.433; catalog v13): a plain gun has
56
- // nothing to broadcast and nothing to crack.
57
- if (gun?.isFirearm() && gun.isSmartgun) {
58
- lines.push(` • ${gun.name} (smartgun) -- ${gun.jammed ? '{red-fg}JAMMED -- smartlink cracked ("reboot"){/red-fg}' : 'smartlink live'}`);
132
+ const over = a.panOverflow();
133
+ if (over > 0) {
134
+ lines.push('');
135
+ lines.push(`{red-fg}${over} device${over === 1 ? '' : 's'} over capacity{/red-fg} -- a master handles only (Device Rating x 3) slaves (p.233). The excess is off the net.`);
59
136
  }
60
- const drone = a.inventory.getAllItems().find(i => i.category === Category.Drone);
61
- if (drone) {
62
- lines.push(` • ${drone.name} (drone) -- recon uplink${hint(' ("drone")')}`);
137
+ // Anything deliberately switched off is listed too: a device you
138
+ // cannot see on this screen is a device you will forget you muted.
139
+ const off = [...a.inventory.getAllItems()].filter(i => !i.wirelessOn && Player.canJoinPan(i));
140
+ if (off.length > 0) {
141
+ lines.push('');
142
+ lines.push('Switched off:');
143
+ for (const item of off) {
144
+ lines.push(` · ${item.name} -- wireless OFF, no icon, nothing to hack${hint(` ("pan on ${item.name}")`)}`);
145
+ }
63
146
  }
64
147
  lines.push('');
65
148
  lines.push(a.runningSilent
@@ -67,5 +150,26 @@ export class PanCommand extends Command {
67
150
  : `Running loud: any decker in reach can try your firewall -- jammed guns and dead AR are how that feels.${hint(' "silent" goes dark.')}`);
68
151
  return lines.join('\n');
69
152
  }
153
+ /** What one slaved device is and what being on the net buys it. */
154
+ slaveLine(item) {
155
+ const a = this.actor;
156
+ const inactive = a.runningSilent || a.arOffline;
157
+ if (item.isCyberdeck()) {
158
+ return `${item.name} (cyberdeck) -- Matrix access; ${item.deckSummary()}${item.isBricked ? ' BRICKED (rest to repair)' : ''}`;
159
+ }
160
+ if (item.category === Category.Commlink) {
161
+ const bonus = inactive ? 'inactive' : `+${item.qualityBonus} initiative (AR overlay)`;
162
+ return `${item.name} (commlink) -- calls; ${bonus}`;
163
+ }
164
+ // Only a SMARTGUN is on the PAN (p.433; catalog v13): a plain gun has
165
+ // nothing to broadcast and nothing to crack.
166
+ if (item.isFirearm()) {
167
+ return `${item.name} (smartgun) -- ${item.jammed ? '{red-fg}JAMMED -- smartlink cracked ("reboot"){/red-fg}' : 'smartlink live'}`;
168
+ }
169
+ if (item.category === Category.Drone) {
170
+ return `${item.name} (drone) -- recon uplink${hint(' ("drone")')}`;
171
+ }
172
+ return `${item.name} (${item.category})`;
173
+ }
70
174
  }
71
175
  //# sourceMappingURL=pan.js.map
@@ -1251,9 +1251,13 @@
1251
1251
  // 'panel' -- the maglock, the keypad, the safe). A ward is the Art
1252
1252
  // and has no wireless mode to hide in.
1253
1253
  // - CAMERAS TOO, which p.217 names FIRST. The cluster is not a Device
1254
- // (it is derived from the room), so it hides on Room.camerasSpottedBy
1255
- // and resists with its master host's rating -- a site's lenses are as
1256
- // quiet as the security that runs them.
1254
+ // (it is derived from the room), so it hides on Room.camerasSpottedBy.
1255
+ // (This entry ORIGINALLY said it resisted with its MASTER HOST'S
1256
+ // rating. That was wrong and 1.80.1 removed it the same day -- a
1257
+ // lens behind a rating-8 host was a 16-die hide against a sweep
1258
+ // capped near 5, and it measured ZERO finds in twelve looks. It
1259
+ // resists with its own rating; see 1.82.0 for where that number
1260
+ // now lives.)
1257
1261
  // - AND PEOPLE. Going dark used to make an icon simply unfindable:
1258
1262
  // isLinked answers false and nothing anywhere rolled to beat it, so
1259
1263
  // the book's own example of a hidden icon was the one case with no
@@ -1325,5 +1329,94 @@
1325
1329
  // hole had been open: they leaned on the old "no gear, stay quiet"
1326
1330
  // branch and have been rolling unlimited all along. They carry decks
1327
1331
  // now. The VR gate (a direct neural interface, p.222-223) is untouched.
1328
- export const ENGINE_VERSION = '1.81.0';
1332
+ // 1.82.0 (2026-09-16): WHAT MATRIX PERCEPTION BUYS MUST BE EARNED
1333
+ // (eJANPpm7qCaCAJBJw, third rejection). 1.80.0 hid the ICON and stopped
1334
+ // there. The reporter read the transcript and found the rest:
1335
+ // "I shouldn't even see 'Service Keypad hides' at all if I don't
1336
+ // clear the firewall."
1337
+ // They were right, and the leak was in the DICE, not the prose. The
1338
+ // narrative line was correctly vague -- "something here is running
1339
+ // silent and you cannot pin it" -- and then the mechanics pane named
1340
+ // the thing on the very next line, win or lose:
1341
+ // Service Keypad hides (Device Rating (no Logic to hide behind)): 6d6 -> 2 hits
1342
+ // A FAILED SWEEP TAUGHT YOU MORE THAN A SUCCESSFUL ONE WOULD HAVE.
1343
+ // - A LOST SWEEP NAMES NOTHING. ISpotOutcome.missed is now a COUNT,
1344
+ // not a list of names, and the losing branch of the loop never puts
1345
+ // a name in the outcome at all. This is the shape of the fix that
1346
+ // matters: the old type carried the names and merely trusted every
1347
+ // caller to remember not to print them. A name that was never earned
1348
+ // cannot now be printed by accident, because it is not there. The
1349
+ // dice still show: "something holds its cover: 6d6 -> 2 hits".
1350
+ // - RATINGS ARE PROTECTED TOO, which is the reporter's other question
1351
+ // answered. p.235 sells "the target's device rating" for a hit, and
1352
+ // the engine printed [DR n] on every icon for free. It is now
1353
+ // [DR ?] until the icon is yours: ratingKnownBy = not silent, or
1354
+ // spotted by you. NOTE THE SMALLER CLAIM -- a never-silent device
1355
+ // shows its rating for nothing. p.235 charges a hit for it even
1356
+ // then; this engine does not, because a loud icon has nothing to
1357
+ // roll against and inventing a resistance for it would be inventing
1358
+ // a rule. Said plainly rather than papered over.
1359
+ // - THE CAMERA CLUSTER HAS A RATING AT LAST, which was the third
1360
+ // complaint. Room.cameraRating, ONE number, feeding all three places
1361
+ // that disagreed: what it hides behind (Device Rating + Firewall),
1362
+ // what it defends Control Device / Data Spike with, and what the
1363
+ // grid prints. It is 1 (p.356 lists Camera at 1; the same list gives
1364
+ // the door lock the 2 the engine already uses). It is a FIELD, so a
1365
+ // corp floor that wants p.421's mil-spec band is a one-line change
1366
+ // in the seed layer, not a rewrite.
1367
+ // - AND THE SWEEP READS AS A SWEEP: the narration moved ABOVE the icon
1368
+ // block (you were reading revealed icons before the sentence that
1369
+ // explained them), and an icon found this look is tagged "<- just
1370
+ // spotted, was running silent". Without the tag, every later look
1371
+ // shows it with no explanation at all -- indistinguishable from an
1372
+ // icon that was never hidden, which is most of why 1.80.0 read to
1373
+ // the reporter as nothing having happened.
1374
+ // 1.83.0 (2026-09-16): THE WIRELESS SWITCH (CgJ6uTfEvTd8JXdDN)
1375
+ // The reporter rejected the last pass and wrote the spec themselves:
1376
+ // "1) new command 'pan' shows you your current connected devices.
1377
+ // 2) 'pan link <device>' ... 3) 'pan link <device> on|off' 4) if no
1378
+ // commlink/deck/control rig is linked and on, and you're a
1379
+ // Technomancer, you default to your persona."
1380
+ // RAG-checked, and it is not a house rule -- it is p.421, "Turning It
1381
+ // Off", almost word for word:
1382
+ // "Toggling an individual device's wireless functionality off is a
1383
+ // Free Action ... YOU LOSE WIRELESS BONUSES, but the items can no
1384
+ // longer be wirelessly hacked."
1385
+ // That sentence is a TRADE, and both halves are now real.
1386
+ // - Item.wirelessOn, default ON. Membership in a PAN stays DERIVED
1387
+ // rather than becoming a list you assemble by hand -- which is the
1388
+ // reporter's own ruling ("most equipment is wireless, so a lot can
1389
+ // just be that easy") and is what keeps every existing character's
1390
+ // network exactly as it was. The switch is the override, not a
1391
+ // chore. Saved SPARSE and INVERTED (`wirelessOff`), so a save
1392
+ // written before today has no key and comes back on the air.
1393
+ // - `pan off <device>` / `pan on <device>`, with the reporter's own
1394
+ // `pan link <device> [on|off]` spelling accepted for the same two
1395
+ // verbs. Billed as p.421's FREE ACTION, which also means free
1396
+ // outside a fight, since billAction only charges in an encounter.
1397
+ // - THE "NO LONGER HACKED" HALF IS REAL, which is the half a switch
1398
+ // like this usually fakes: a muted commlink stops making you
1399
+ // isLinked (no calls, and no PAN icon for a sweep to find), a muted
1400
+ // gun has no grid icon for `hack` to seize, and a muted deck cannot
1401
+ // master a PAN.
1402
+ // - AND THE "LOSE WIRELESS BONUSES" HALF, split exactly where p.421
1403
+ // and p.433 split it: the smartgun's wireless +1 DICE goes, and its
1404
+ // +2 ACCURACY STAYS -- that one rides a cable to your glasses and
1405
+ // never needed the radio. Same discipline as the Matrix brackets: a
1406
+ // limit is never a die.
1407
+ // - Player.panSlaves/panCapacity/panOverflow, so the dashboard, the
1408
+ // capacity rule and "is this on the net?" all read ONE list. p.233
1409
+ // caps a master at (Device Rating x 3) slaves and the engine had
1410
+ // never counted; over-capacity is now said out loud rather than
1411
+ // silently truncated. This is the same drift that cost us three
1412
+ // disagreeing camera ratings in 1.82.0.
1413
+ // NOT SHIPPED, deliberately, and it is the other half of the item: a
1414
+ // technomancer deliberately RIDING a deck ("Using Mundane
1415
+ // Electronics", p.251). A device persona mutes every Resonance
1416
+ // ability, so that mode is worth nothing until compile/register/
1417
+ // thread and their siblings all know about it -- shipping the switch
1418
+ // alone would be a fresh canon violation wearing a feature's clothes.
1419
+ // A technomancer still comes up on the living persona, which is what
1420
+ // the reporter's point 4 asks for.
1421
+ export const ENGINE_VERSION = '1.83.0';
1329
1422
  //# sourceMappingURL=engine-version.js.map
@@ -49,6 +49,24 @@ export class Item extends AbstractItem {
49
49
  // work (rest.ts). A reboot does NOT clear it; that is the whole
50
50
  // difference from `jammed`.
51
51
  bricked = false;
52
+ /**
53
+ * THE WIRELESS SWITCH (p.233 PANs and WANs; CgJ6uTfEvTd8JXdDN: "'pan
54
+ * link <device> on|off'"). Slaving is a thing an owner CHOOSES, and
55
+ * the choice has a price both ways: a device on the network borrows
56
+ * its master's ratings to defend itself, and a device off it is not
57
+ * on the Matrix at all -- no wireless bonus, no icon to find, nothing
58
+ * to hack.
59
+ *
60
+ * DEFAULT ON, which is the reporter's own ruling ("most equipment is
61
+ * wireless, so a lot can just be that easy") and is why membership
62
+ * stays DERIVED rather than becoming a list you have to build by
63
+ * hand. Every runner who never touches `pan` keeps exactly the PAN
64
+ * they have today; this switch is the override.
65
+ *
66
+ * Saved SPARSE, as `wirelessOff` -- absent means on, so every save
67
+ * written before this existed restores unchanged.
68
+ */
69
+ wirelessOn = true;
52
70
  /**
53
71
  * RUINED (Room.layOutBody, catalog v21 NPC kits): armor that soaked
54
72
  * the rounds that killed its wearer. Rated 0 (armorValue, armorBonus)
@@ -544,10 +544,11 @@ export class Player extends AbstractPlayer {
544
544
  // across jack-outs, and a full track BRICKS the deck -- forced dump,
545
545
  // and no re-entry on that deck until it's repaired.
546
546
  activeDeck;
547
- /** The best working cyberdeck carried (bricked ones don't count). */
547
+ /** The best working cyberdeck carried (bricked ones don't count, and
548
+ * neither does one you have switched off -- p.233, `pan off`). */
548
549
  getCyberdeck() {
549
550
  return this._inventory.getAllItems()
550
- .filter(i => i.isCyberdeck() && !i.isBricked)
551
+ .filter(i => i.isCyberdeck() && !i.isBricked && i.wirelessOn)
551
552
  .sort((a, b) => b.qualityBonus - a.qualityBonus)[0];
552
553
  }
553
554
  /** Any deck at all, bricked included -- for status displays and repair. */
@@ -960,13 +961,82 @@ export class Player extends AbstractPlayer {
960
961
  // Worn first, then CARRIED (playtest 2026-08-24: NPCs keep their
961
962
  // links in inventory, not the head slot -- a pocketed commlink is
962
963
  // still the PAN's master; only running silent hides it).
963
- const link = this._equipment.head?.commlink
964
- ?? this._inventory.getAllItems().find(i => i.category === 'commlink')
964
+ // A device switched off masters nothing (p.233, `pan off`).
965
+ const worn = this._equipment.head?.commlink;
966
+ const link = (worn?.wirelessOn ? worn : undefined)
967
+ ?? this._inventory.getAllItems().find(i => i.category === 'commlink' && i.wirelessOn)
965
968
  ?? undefined;
966
969
  if (deck && link)
967
970
  return deck.qualityBonus >= link.qualityBonus ? deck : link;
968
971
  return deck ?? link;
969
972
  }
973
+ /**
974
+ * HOW MANY SLAVES A MASTER CAN HOLD: Device Rating x 3 (p.233, "your
975
+ * commlink or deck can handle up to (Device Rating x 3) slaved
976
+ * devices"). Zero without a master, because a PAN is defined as the
977
+ * slaves PLUS the master -- there is no network to join.
978
+ */
979
+ panCapacity() {
980
+ const master = this.getPanMaster();
981
+ return master ? master.deviceRating * 3 : 0;
982
+ }
983
+ /**
984
+ * THE PAN, ITEMISED (CgJ6uTfEvTd8JXdDN: "new command 'pan' shows you
985
+ * your current connected devices"). One list, so the dashboard, the
986
+ * capacity check and anything that asks "is this on the network?"
987
+ * cannot drift apart -- which is the bug pattern the camera cluster
988
+ * just cost us in eJANPpm7qCaCAJBJw.
989
+ *
990
+ * MEMBERSHIP IS DERIVED, not a hand-built list, per the reporter's own
991
+ * ruling: "most equipment is wireless, so a lot can just be that
992
+ * easy." What you carry that can be on a PAN, is -- unless you switch
993
+ * it off. So no existing character's network changes, and `pan off`
994
+ * is the override rather than `pan link` being a chore.
995
+ *
996
+ * Only DEVICES can be slaves (p.233), which is why a plain revolver is
997
+ * not here and a smartgun is: the smartlink is the device.
998
+ */
999
+ panSlaves() {
1000
+ const master = this.getPanMaster();
1001
+ if (!master)
1002
+ return [];
1003
+ const carried = this._inventory.getAllItems();
1004
+ const worn = [
1005
+ this._equipment.head?.commlink,
1006
+ this._equipment.rightHand?.weapon,
1007
+ this._equipment.leftHand?.weapon,
1008
+ ].filter((i) => i !== undefined && i !== null);
1009
+ const seen = new Set([master]);
1010
+ const out = [];
1011
+ for (const item of [...worn, ...carried]) {
1012
+ if (seen.has(item) || !item.wirelessOn)
1013
+ continue;
1014
+ if (!Player.canJoinPan(item))
1015
+ continue;
1016
+ seen.add(item);
1017
+ out.push(item);
1018
+ }
1019
+ // Over capacity is the master's problem, not a silent truncation --
1020
+ // panOverflow() reports the excess so `pan` can say so out loud.
1021
+ return out.slice(0, this.panCapacity());
1022
+ }
1023
+ /** Slaves that do not fit under the master's (DR x 3) ceiling. */
1024
+ panOverflow() {
1025
+ const master = this.getPanMaster();
1026
+ if (!master)
1027
+ return 0;
1028
+ const wanted = this._inventory.getAllItems().filter(i => i !== master && i.wirelessOn && Player.canJoinPan(i)).length;
1029
+ return Math.max(0, wanted - this.panCapacity());
1030
+ }
1031
+ /** Whether an item is the kind of thing a PAN can hold at all (p.233:
1032
+ * "only devices can be slaves, masters, or part of a PAN"). */
1033
+ static canJoinPan(item) {
1034
+ if (item.isCyberdeck() || item.category === Category.Commlink)
1035
+ return true;
1036
+ if (item.isFirearm())
1037
+ return item.isSmartgun;
1038
+ return item.category === Category.Drone || item.category === Category.Electronics;
1039
+ }
970
1040
  /**
971
1041
  * Defense against a PAN intrusion, itemised: the book's attribute plus
972
1042
  * the master's whole Firewall, +2 while running silent (a hidden PAN
@@ -3343,8 +3413,15 @@ export class Player extends AbstractPlayer {
3343
3413
  // (p.433), and a crossbow has nothing to plug into.
3344
3414
  // AND THE GUN MUST BE A SMARTGUN (catalog v13, p.433): the wireless
3345
3415
  // bonus is the smartgun system talking to the smartlink.
3416
+ // AND THE WIRELESS MUST BE ON, which is the whole of p.421's
3417
+ // "Turning It Off": "You lose wireless bonuses, but the items can no
3418
+ // longer be wirelessly hacked." THIS is the bonus that goes -- the
3419
+ // dice -- while smartlinkAccuracyBonus's +2 Accuracy STAYS, because
3420
+ // that one is the smartgun talking to your smartlink over a cable to
3421
+ // your glasses and never needed the radio. Same discipline as the
3422
+ // Matrix brackets: a limit is never a die (CgJ6uTfEvTd8JXdDN).
3346
3423
  const drawnGun = this.weaponDrawn ? this.getCarriedWeapon() : null;
3347
- const smartlink = drawnGun?.isSmartgun && FIREARM_SKILLS.includes(this.activeCombatSkill())
3424
+ const smartlink = drawnGun?.isSmartgun && drawnGun.wirelessOn && FIREARM_SKILLS.includes(this.activeCombatSkill())
3348
3425
  // Chrome again, and for the same reason: the tier is already
3349
3426
  // decided above, and this branch is the implant's own dice.
3350
3427
  ? (this.smartlinkTier() === 'gear' ? 1 : this.augBonus('firearms'))
@@ -12,6 +12,13 @@ import { AbstractRoom } from '../types/shared/abstracts.js';
12
12
  import { spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
13
13
  import { canHearSpeech, canHearShout } from '../utilities/earshot.js';
14
14
  import { synthesizeGrid, attachExitCell, detachExitCell, } from '../utilities/room-grid.js';
15
+ /**
16
+ * A CAMERA'S DEVICE RATING when the scene does not say (p.356, which
17
+ * lists "Camera" at 1 -- the same table that gives the door lock the 2
18
+ * DEFAULT_DEVICE_RATING already uses). A seed raises it: p.421 bands
19
+ * ordinary security devices at 2 and mil-spec at 4.
20
+ */
21
+ export const DEFAULT_CAMERA_RATING = 1;
15
22
  export class Room extends AbstractRoom {
16
23
  logger = Logger.getInstance();
17
24
  actors = new Set();
@@ -74,6 +81,29 @@ export class Room extends AbstractRoom {
74
81
  * of the grid.
75
82
  */
76
83
  camerasSpottedBy = new Set();
84
+ /**
85
+ * WHAT THE LENSES RATE (eJANPpm7qCaCAJBJw, third rejection: "the
86
+ * camera cluster doesn't have a DR"), and the reporter's ruling that
87
+ * it should be per-site rather than one global number.
88
+ *
89
+ * The cluster is not a Device -- it is derived from the room itself
90
+ * (utilities/surveillance.ts isWatched) -- so it had no rating to
91
+ * carry, and the codebase had quietly grown THREE answers that
92
+ * disagreed: it hid behind a hard-coded 4, it defended a Data Spike
93
+ * with the HOST's pool (commands/disable.ts), and it displayed no
94
+ * rating at all. One number now feeds all three, so what a player
95
+ * reads is what it rolls.
96
+ *
97
+ * ONE FOR NOW (user ruling 2026-09-16), which is what p.356 lists a
98
+ * Camera at -- the same table the engine already takes its door-lock 2
99
+ * from. Per-site seeding was scoped out deliberately: the defect here
100
+ * was three numbers disagreeing, and one field fixes that without a
101
+ * seed knob the generator could get wrong. It is a FIELD rather than a
102
+ * constant so that making it per-site later is a one-line change at
103
+ * the seed layer and nothing else moves. p.421 bands ordinary security
104
+ * devices at 2 and mil-spec at 4 when that day comes.
105
+ */
106
+ cameraRating = DEFAULT_CAMERA_RATING;
77
107
  /**
78
108
  * THE FALLEN (player ruling 2026-08-25). A killed actor leaves a BODY
79
109
  * where they dropped, holding everything they carried, instead of
@@ -2,6 +2,7 @@ import { isLinked, CALL_COLOR } from './comm-style.js';
2
2
  import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
3
3
  import { Category } from '../types/shared/item-enum.js';
4
4
  import { hint } from './hints.js';
5
+ import { cameraDrTag, drTagFor } from './grid-view.js';
5
6
  /**
6
7
  * AROs -- AUGMENTED REALITY OBJECTS (player request 2026-08-26: "we
7
8
  * need AROs on anything online"). The 2075 street is not bare: anything
@@ -147,9 +148,10 @@ export function aroTags(scene, actor, room) {
147
148
  // The verb rides the tag: standing in the room IS the requirement
148
149
  // for a direct connection (commands/tap.ts), so this is the one
149
150
  // place the player is always in a position to use it.
151
+ const camDr = cameraDrTag(room, actor.name);
150
152
  tags.push(canSnoopFeeds(rooms, room, actor) && !camerasLive(rooms, room)
151
- ? `◎ Camera cluster -- wireless, and looping empty hallways for the house.`
152
- : `◎ Camera cluster -- wireless, and watching.${hint(` ("tap" cables into it -- a slaved lens can't hide behind its host)`)}`);
153
+ ? `◎ Camera cluster ${camDr}-- wireless, and looping empty hallways for the house.`
154
+ : `◎ Camera cluster ${camDr}-- wireless, and watching.${hint(` ("tap" cables into it -- a slaved lens can't hide behind its host)`)}`);
153
155
  }
154
156
  // THE FIXED DEVICES -- the keypad on the wall, the maglock on the
155
157
  // grate (YjBA9xhY2NDmCFw5d, Deditri: "The keypad should be
@@ -174,7 +176,7 @@ export function aroTags(scene, actor, room) {
174
176
  // still picked, breached, shot and keyed exactly as before.
175
177
  if (!device.hasIconFor(actor.name))
176
178
  continue;
177
- tags.push(`▤ ${device.name} -- wireless, {bold}[DR ${device.rating}]{/bold}${device.isOpen() ? ', standing open' : `.${hint(` ("${device.verbs()[0]} ${device.name}")`)}`}`);
179
+ tags.push(`▤ ${device.name} -- wireless, ${drTagFor(device, actor.name)}${device.isOpen() ? ', standing open' : `.${hint(` ("${device.verbs()[0]} ${device.name}")`)}`}`);
178
180
  }
179
181
  // LOOSE DEVICES lying in plain sight. A wireless deck on a table
180
182
  // announces itself; one in a drawer you never opened does not.
@@ -26,9 +26,17 @@ export const END_CALL_SENTINEL = 'end-call';
26
26
  export function isLinked(actor) {
27
27
  if (actor.runningSilent)
28
28
  return false;
29
- const worn = actor.equipment.head?.commlink != null;
30
- const carried = actor.inventory.getAllItems().some(i => i.category === 'commlink');
31
- return worn || carried;
29
+ // A LINK YOU SWITCHED OFF IS NOT A LINK (p.233, `pan off <device>`;
30
+ // CgJ6uTfEvTd8JXdDN). This used to ask only whether a commlink was
31
+ // present, so a runner who deliberately took theirs off the air still
32
+ // took calls and still hung a PAN icon on the grid for anyone to
33
+ // find -- the switch would have been a lie everywhere it mattered.
34
+ // Undefined counts as ON, so every caller that passes a plain shape
35
+ // (NPC fixtures, saves written before the switch) is unchanged.
36
+ const worn = actor.equipment.head?.commlink;
37
+ const wornOn = worn != null && worn.wirelessOn !== false;
38
+ const carried = actor.inventory.getAllItems().some(i => i.category === 'commlink' && i.wirelessOn !== false);
39
+ return wornOn || carried;
32
40
  }
33
41
  /**
34
42
  * DROP A LIVE CALL, BOTH SIDES, and say who was on it.
@@ -23,6 +23,65 @@ const NEAR_DIVIDER = '-- and NEAR it, out on the open grid (not inside the host)
23
23
  export function isNearDivider(line) {
24
24
  return line === NEAR_DIVIDER;
25
25
  }
26
+ /**
27
+ * A DEVICE RATING IS SOMETHING YOU BUY WITH A HIT, not something the
28
+ * screen gives away (eJANPpm7qCaCAJBJw -- "are device ratings
29
+ * themselves, protected information?"; p.235).
30
+ *
31
+ * p.235 lists what one hit on a Matrix Perception test buys, and "the
32
+ * target's device rating" is an entry on it, beside "the type of icon"
33
+ * and "the rating of one of the target's Matrix attributes". The engine
34
+ * printed [DR n] on every icon for nothing, which is the same defect the
35
+ * silent-icon work fixed one field over: the rating was free.
36
+ *
37
+ * WHAT COUNTS AS HAVING READ IT is the sweep that found the icon
38
+ * (utilities/silent-icons.ts). Winning it buys the icon AND its rating
39
+ * together -- one hit, one look, one fact.
40
+ *
41
+ * AND A LOUD ICON PUBLISHES ITS RATING, which is where this stops short
42
+ * of p.235 on purpose. Strictly the book charges a hit for the rating of
43
+ * ANY icon, loud or not; modelling that needs a per-fact ledger of what
44
+ * each viewer has bought about each icon, which nothing else in this
45
+ * engine keeps and which would put a roll between a player and a number
46
+ * they can already see on the shop window. The first cut gated every
47
+ * rating on `spottedBy` and the consequence was immediate: a device that
48
+ * was never silent is never spotted, so its rating read [DR ?] forever
49
+ * with no way to earn it. So the line is drawn at HIDING -- a rating you
50
+ * were never meant to see is protected, a rating the thing broadcasts is
51
+ * not -- and that is a smaller claim than p.235's, stated here rather
52
+ * than dressed up as the whole rule.
53
+ */
54
+ export function ratingKnownBy(device, viewerName) {
55
+ return !device.silent || device.spottedBy.has(viewerName);
56
+ }
57
+ /**
58
+ * The bracketed rating, or `[DR ?]` when it has not been read.
59
+ *
60
+ * A QUESTION MARK RATHER THAN NOTHING, on purpose: the icon's SHAPE is
61
+ * free (p.219 -- it looks like the thing it is), so a player should see
62
+ * that this object HAS a rating and that they have not bought it yet.
63
+ * Printing nothing would read as "this one has no rating", which is a
64
+ * different and wrong fact, and is exactly the ambiguity that made the
65
+ * camera cluster's missing DR look like an oversight.
66
+ */
67
+ export function drTag(known, rating) {
68
+ return known ? `{bold}[DR ${rating}]{/bold}` : `{bold}[DR ?]{/bold}`;
69
+ }
70
+ /** One rule for both surfaces: the grid and the AR overlay ask this, so
71
+ * a rating the street can read and a persona cannot is impossible. */
72
+ export function drTagFor(device, viewerName) {
73
+ return drTag(ratingKnownBy(device, viewerName), device.rating);
74
+ }
75
+ /** The camera cluster's rating tag. Cameras are always security kit and
76
+ * therefore always silent, so unlike a device there is no loud case:
77
+ * reading the lenses is the only way to their number. */
78
+ export function cameraDrTag(room, viewerName) {
79
+ return drTag(room.camerasSpottedBy.has(viewerName), room.cameraRating);
80
+ }
81
+ /** Trailing space for the icon lines that interpolate it mid-sentence. */
82
+ function cameraDr(room, viewerName) {
83
+ return `${cameraDrTag(room, viewerName)} `;
84
+ }
26
85
  /**
27
86
  * A DEVICE ICON, AND WHAT WORKS IT (commands/disable.ts, 2026-09-13).
28
87
  * Control Device (p.238) needs 2 marks on the device -- "disable" -- and
@@ -30,7 +89,7 @@ export function isNearDivider(line) {
30
89
  * locked (p.228), so its line says so rather than offering a way in.
31
90
  */
32
91
  export function deviceIconLine(device, actor) {
33
- const dr = `{bold}[DR ${device.rating}]{/bold}`;
92
+ const dr = drTag(ratingKnownBy(device, actor.name), device.rating);
34
93
  if (device.isOpen())
35
94
  return `▤ ${device.name} ${dr} -- its icon standing open, nothing left to hold.`;
36
95
  if (device.isBricked) {
@@ -204,16 +263,22 @@ export function gridIcons(scene, actor, room) {
204
263
  // SAME SELECTOR AS hack.ts's top-priority PAN effect, on
205
264
  // purpose: the icon must name exactly the device the verb will
206
265
  // seize, or "identified rapidly" points at the wrong thing.
207
- // This engine treats a held firearm as smartlinked -- there is
208
- // no separate wireless flag on Item -- so held-and-a-firearm is
209
- // the whole test, and the two surfaces agree by construction.
266
+ // This engine treats a held firearm as smartlinked, so
267
+ // held-and-a-firearm is the whole test and the two surfaces
268
+ // agree by construction -- EXCEPT for a gun whose owner has
269
+ // switched it off the net (Item.wirelessOn, `pan off <gun>`,
270
+ // CgJ6uTfEvTd8JXdDN). A device off the network broadcasts
271
+ // nothing, so there is no icon here to identify and nothing for
272
+ // `hack` to seize; the trade is p.421's, priced there -- the
273
+ // wireless DICE bonus goes (getAttackPool reads the same flag)
274
+ // while the smartgun's +2 Accuracy stays.
210
275
  //
211
276
  // Sitting INSIDE the isLinked branch is what makes running
212
277
  // silent hide it, which is canon too: the book notes a user can
213
278
  // hide these icons, and isLinked() is already false while
214
279
  // silent. No second rule needed.
215
280
  const gun = a.equipment.rightHand?.weapon ?? a.equipment.leftHand?.weapon;
216
- if (gun?.isFirearm()) {
281
+ if (gun?.isFirearm() && gun.wirelessOn) {
217
282
  // TERSE ON PURPOSE (AKfemBJJWJP99nx8K, third pass: "overly
218
283
  // verbose"). The earlier line explained WHY a gun breaks out
219
284
  // of the PAN -- "a wireless gun does not hide inside a
@@ -275,10 +340,10 @@ export function gridIcons(scene, actor, room) {
275
340
  if (isWatched(room) && room.camerasSpottedBy.has(actor.name)) {
276
341
  const rooms = scene.getRooms();
277
342
  if (!camerasLive(rooms, room)) {
278
- icons.push(`◎ Camera cluster -- looped on empty hallways. Security sees NOTHING here.${canSnoopFeeds(rooms, room, actor) ? ` ("snoop" rides the feeds you own)` : ''}`);
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)` : ''}`);
279
344
  }
280
345
  else if (canSnoopFeeds(rooms, room, actor)) {
281
- icons.push(`◎ Camera cluster -- live, and answering to YOU as much as the house. ("snoop <room>" watches remotely)`);
346
+ icons.push(`◎ Camera cluster ${cameraDr(room, actor.name)}-- live, and answering to YOU as much as the house. ("snoop <room>" watches remotely)`);
282
347
  }
283
348
  else {
284
349
  // Seen from the grid, the cluster is worth naming as a WAY IN,
@@ -296,7 +361,7 @@ export function gridIcons(scene, actor, room) {
296
361
  // exactly one, so the reader was left to guess at a fact the
297
362
  // engine had already handed them.
298
363
  const owner = cameraMasterHost(scene.getRooms(), room);
299
- icons.push(`◎ Camera cluster -- live, feeding ${owner ? hostLabel(owner) : `the site's host`}. Loud acts here travel. (own the host to loop them -- or walk in and "tap" a lens in the flesh: slaved to the host means a key to the host)`);
364
+ icons.push(`◎ Camera cluster ${cameraDr(room, actor.name)}-- live, feeding ${owner ? hostLabel(owner) : `the site's host`}. Loud acts here travel. (own the host to loop them -- or walk in and "tap" a lens in the flesh: slaved to the host means a key to the host)`);
300
365
  }
301
366
  }
302
367
  // THE DIVIDER (eJANPpm7qCaCAJBJw). Only when there is a host to be
@@ -570,14 +635,28 @@ export function gridSculpt(actor, room) {
570
635
  * the dimmer their icons"). `rooms` absent means the caller has only
571
636
  * the one room, and the block reads as it always did.
572
637
  */
573
- export function gridIconBlock(scene, actor, room, rooms) {
638
+ export function gridIconBlock(scene, actor, room, rooms, justFound) {
574
639
  const isHostLine = (i) => i.startsWith('●') || i.startsWith('■');
640
+ // WHAT THIS LOOK JUST TURNED UP, tagged where it is listed
641
+ // (eJANPpm7qCaCAJBJw: "I still see the Service Keypad and Camera
642
+ // cluster"). An icon a sweep has just won is otherwise identical on
643
+ // the page to one that was never hidden -- and on every LATER look
644
+ // there is no sweep line at all, because there is nothing left to
645
+ // roll against, so without this the moment of finding something is
646
+ // invisible and the whole mechanic reads as not having run.
647
+ const mark = (icon) => {
648
+ if (!justFound?.length)
649
+ return icon;
650
+ return justFound.some(n => icon.includes(n))
651
+ ? `${icon} {bold}<- just spotted, was running silent{/bold}`
652
+ : icon;
653
+ };
575
654
  if (actor.insideHost || !rooms) {
576
655
  const icons = gridIcons(scene, actor, room);
577
656
  if (icons.length === 0) {
578
657
  return `{light-blue-fg}Thin grid out here -- background noise, nothing worth cracking.{/light-blue-fg}`;
579
658
  }
580
- return `{light-blue-fg}ICONS IN REACH:{/light-blue-fg}\n${icons.map(i => ` {light-blue-fg}${i}{/light-blue-fg}`).join('\n')}`;
659
+ return `{light-blue-fg}ICONS IN REACH:{/light-blue-fg}\n${icons.map(i => ` {light-blue-fg}${mark(i)}{/light-blue-fg}`).join('\n')}`;
581
660
  }
582
661
  const out = [];
583
662
  const hosts = hostsInReach(rooms, actor);
@@ -629,12 +708,12 @@ export function gridIconBlock(scene, actor, room, rooms) {
629
708
  return line;
630
709
  // Box-drawing, so the last child closes the branch and the eye can
631
710
  // see where the WAN ends.
632
- const kids = slaved.map((i, idx) => ` {light-blue-fg}${idx === slaved.length - 1 ? '└─' : '├─'} ${i}{/light-blue-fg}`);
711
+ const kids = slaved.map((i, idx) => ` {light-blue-fg}${idx === slaved.length - 1 ? '└─' : '├─'} ${mark(i)}{/light-blue-fg}`);
633
712
  return [line, ...kids].join('\n');
634
713
  }).join('\n')}`
635
714
  : `{light-blue-fg}Nothing overhead -- no host stands over this district.{/light-blue-fg}`);
636
715
  out.push(loose.length > 0
637
- ? `{light-blue-fg}ICONS IN REACH${slaved.length > 0 ? ' -- answering to nobody up there' : ''}:{/light-blue-fg}\n${loose.map(i => ` {light-blue-fg}${i}{/light-blue-fg}`).join('\n')}`
716
+ ? `{light-blue-fg}ICONS IN REACH${slaved.length > 0 ? ' -- answering to nobody up there' : ''}:{/light-blue-fg}\n${loose.map(i => ` {light-blue-fg}${mark(i)}{/light-blue-fg}`).join('\n')}`
638
717
  : slaved.length > 0
639
718
  ? `{light-blue-fg}Nothing else near -- everything around you hangs off the host.{/light-blue-fg}`
640
719
  : `{light-blue-fg}Nothing near -- background noise around your signal.{/light-blue-fg}`);
@@ -165,6 +165,10 @@ export function serializeItem(item) {
165
165
  out.jammed = true;
166
166
  if (item.bricked)
167
167
  out.bricked = true;
168
+ // Sparse and INVERTED on purpose: wireless is on by default, so a save
169
+ // written before the switch existed has no key and restores to on.
170
+ if (!item.wirelessOn)
171
+ out.wirelessOff = true;
168
172
  if (item.deckDamage > 0)
169
173
  out.deckDamage = item.deckDamage;
170
174
  if (item.droneDamage > 0)
@@ -224,6 +228,8 @@ export function restoreItem(json) {
224
228
  item.jammed = true;
225
229
  if (json.bricked)
226
230
  item.bricked = true;
231
+ if (json.wirelessOff)
232
+ item.wirelessOn = false;
227
233
  if (json.ruined)
228
234
  item.ruined = true;
229
235
  if (json.deckDamage)
@@ -1,7 +1,6 @@
1
1
  import { rollPool, formatRoll } from './dice.js';
2
2
  import { deviceDefensePool } from './matrix-intrusion.js';
3
3
  import { isWatched } from './surveillance.js';
4
- import { DEFAULT_DEVICE_RATING } from '../models/device.js';
5
4
  function hiddenDevice(device) {
6
5
  return {
7
6
  name: device.name,
@@ -34,25 +33,27 @@ export function hiddenIconsIn(scene, room, viewer) {
34
33
  .map(hiddenDevice);
35
34
  // THE CAMERAS, which p.217 names FIRST. They are not a Device -- the
36
35
  // cluster is derived from the room (surveillance.ts isWatched) -- so
37
- // they hide on the room's own set, and they hide behind their OWN
38
- // rating: an ordinary fixture, which canon prices at 2 (p.356).
36
+ // the rating rides the ROOM (Room.cameraRating), seeded per site.
39
37
  //
40
38
  // THE FIRST CUT GAVE THEM THEIR MASTER HOST'S RATING and that was
41
39
  // wrong twice over. Canon first: p.233's slaving rule is about what a
42
- // slave DEFENDS AN ATTACK with -- "whenever a slaved device is called
43
- // on to make a defense test" -- and hiding is not an attack being
44
- // defended. Borrowing the host's numbers here was an extrapolation
45
- // with nothing under it, which is exactly what this project's rule
46
- // against inventing canon is for. And then arithmetic: a rating-8
47
- // host gave its lenses 16 dice against a sweep the Data Processing
48
- // limit caps near 5, so a camera on any corporate host was not hard
49
- // to find, it was impossible. Measured before shipping the fix --
50
- // zero finds in twelve looks.
40
+ // slave DEFENDS AN ATTACK with, and hiding is not an attack being
41
+ // defended. Then arithmetic: a rating-8 host gave its lenses 16 dice
42
+ // against a sweep the Data Processing limit caps near 5, so a camera
43
+ // on any corporate host was not hard to find, it was impossible --
44
+ // measured at zero finds in twelve looks.
45
+ //
46
+ // THE SECOND CUT hard-coded a 4 in here, which fixed the arithmetic
47
+ // and left the engine holding THREE answers to one question: this
48
+ // hid behind 4, commands/disable.ts defended a Data Spike with the
49
+ // HOST's pool, and the icon line showed no rating at all. A player
50
+ // could not have reconciled them because two of the three were never
51
+ // printed. One seeded number feeds all three now, so what the player
52
+ // reads is what it rolls (reporter's ruling: per-site, not global).
51
53
  if (isWatched(room) && !room.camerasSpottedBy.has(viewer.name)) {
52
- const rating = DEFAULT_DEVICE_RATING.lock ?? 2;
53
54
  out.push({
54
55
  name: 'Camera cluster',
55
- hide: { pool: Math.max(0, rating * 2), label: 'Device Rating' },
56
+ hide: { pool: Math.max(0, room.cameraRating * 2), label: 'Device Rating + Firewall' },
56
57
  reveal: (v) => { room.camerasSpottedBy.add(v); },
57
58
  });
58
59
  }
@@ -98,28 +99,44 @@ export function spotHiddenIcons(scene, actor, room) {
98
99
  + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
99
100
  const roll = rollPool(pool, actor.matrixAttribute('dataProcessing'), { gremlins: actor.deckGremlins });
100
101
  const found = [];
101
- const missed = [];
102
+ let missed = 0;
102
103
  const against = [];
103
104
  for (const icon of hidden) {
104
105
  const resist = rollPool(icon.hide.pool);
105
- against.push({ name: icon.name, label: icon.hide.label, roll: resist });
106
106
  // Ties go to the hider: "on a tie or more hits by the defender, it
107
107
  // stays hidden and out of reach" (p.236).
108
108
  if (roll.hits > resist.hits) {
109
109
  icon.reveal(actor.name);
110
110
  found.push(icon.name);
111
+ against.push({ name: icon.name, label: icon.hide.label, roll: resist });
111
112
  }
112
113
  else {
113
- missed.push(icon.name);
114
+ missed += 1;
115
+ // THE NAME DOES NOT LEAVE THIS BRANCH. Only the dice do -- see
116
+ // ISpotOutcome.missed. Attaching the name here "just for the
117
+ // mechanics line" is exactly how it leaked the first time.
118
+ against.push({ roll: resist });
114
119
  }
115
120
  }
116
121
  return { found, missed, roll, against };
117
122
  }
118
- /** The mechanics lines for a sweep, for a screen that shows them. */
123
+ /**
124
+ * The mechanics lines for a sweep.
125
+ *
126
+ * AN ICON YOU DID NOT BEAT IS NOT NAMED HERE (user ruling 2026-09-16).
127
+ * Your own roll is always shown -- it is yours. What it was rolled
128
+ * against is shown only where you won it: a loss prints its dice and
129
+ * calls the roller "something", because naming it would hand over for
130
+ * free precisely what the opposed test exists to charge for (p.235:
131
+ * the type of an icon and its device rating are things a Matrix
132
+ * Perception HIT buys).
133
+ */
119
134
  export function spotMechanics(outcome) {
120
135
  return [
121
136
  `Matrix Perception, silent icons (Computer + Intuition [Data Processing]): ${formatRoll(outcome.roll)}`,
122
- ...outcome.against.map(a => ` ${a.name} hides (${a.label}): ${formatRoll(a.roll)}`),
137
+ ...outcome.against.map(a => (a.name
138
+ ? ` ${a.name} hides (${a.label}): ${formatRoll(a.roll)}`
139
+ : ` something holds its cover: ${formatRoll(a.roll)}`)),
123
140
  ];
124
141
  }
125
142
  /**
@@ -135,10 +152,14 @@ export function spotLine(outcome) {
135
152
  if (outcome.found.length > 0) {
136
153
  lines.push(`You pick ${outcome.found.length === 1 ? 'an icon' : `${outcome.found.length} icons`} out of the noise -- ${outcome.found.join(', ')}, running silent and not quiet enough.`);
137
154
  }
138
- if (outcome.missed.length > 0) {
139
- lines.push(outcome.missed.length === 1
155
+ if (outcome.missed > 0) {
156
+ // A COUNT AND NOTHING ELSE. You learn that the room is holding
157
+ // something back -- which is what a hit buys under p.235-236 -- and
158
+ // not what it is. `missed` is a number precisely so this line cannot
159
+ // grow a name later.
160
+ lines.push(outcome.missed === 1
140
161
  ? `Something else here is running silent and you cannot pin it -- a shape in the static that stops when you look at it. (Look again; each sweep is its own Matrix Perception, p.241.)`
141
- : `${outcome.missed.length} more icons are running silent here, and none of them will hold still. (Look again; each sweep is its own Matrix Perception, p.241.)`);
162
+ : `${outcome.missed} more icons are running silent here, and none of them will hold still. (Look again; each sweep is its own Matrix Perception, p.241.)`);
142
163
  }
143
164
  return lines.join('\n');
144
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.227.0",
3
+ "version": "5.229.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.",