@maka/maka-cli 5.219.0 → 5.221.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.219.0",
3
+ "version": "5.221.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,7 +72,7 @@ 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.name.toLowerCase().includes(raw)
75
+ const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name)).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;
@@ -569,7 +569,7 @@ export class HackCommand extends BypassCommand {
569
569
  if (!named)
570
570
  return null;
571
571
  const raw = named.toLowerCase();
572
- const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.name.toLowerCase().includes(raw)
572
+ const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.hasIconFor(actor.name)).filter(d => d.name.toLowerCase().includes(raw)
573
573
  || (d.opensExit ?? '').toLowerCase() === raw
574
574
  || d.description.toLowerCase().includes(raw));
575
575
  // INSIDE THE HOST you are directly connected to every device on
@@ -19,6 +19,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
19
19
  import { findCatalogItem, describeEffect } from '../utilities/catalog.js';
20
20
  import { billAction } from '../utilities/action-cost.js';
21
21
  import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
22
+ import { spotHiddenIcons, spotLine, spotMechanics } from '../utilities/silent-icons.js';
22
23
  export class LookCommand extends Command {
23
24
  static verb = 'look';
24
25
  static description = 'Examine your surroundings';
@@ -322,8 +323,25 @@ export class LookCommand extends Command {
322
323
  // IS the meat world: the furniture, the smells, the people leaning
323
324
  // on it. gridSculpt (grid-view.ts) orients on what a persona
324
325
  // actually stands in: the room's name and the density of its grid.
326
+ // SWEEP FOR WHAT IS HIDING, BEFORE DRAWING THE LIST (p.217: "some
327
+ // icons are deliberately hidden from view, such as locks and other
328
+ // security devices ... and of course people who prefer not to be
329
+ // seen"; p.235-236, p.241 for the test). `look` on the grid IS
330
+ // Matrix Perception and is already billed as one above, so the
331
+ // sweep costs exactly what canon charges and a second look is a
332
+ // second action. Run FIRST so anything this sweep turns up appears
333
+ // in the icon block below rather than one look later.
334
+ const swept = spotHiddenIcons(this.scene, this.actor, this.actor.insideHost === room ? room : gridVicinity(this.actor));
325
335
  const lines = [gridSculpt(this.actor, room)];
326
336
  lines.push(gridIconBlock(this.scene, this.actor, room, this.rooms));
337
+ if (swept) {
338
+ lines.push(spotLine(swept));
339
+ if (showsMechanics(this.scene, this.actor)) {
340
+ for (const m of spotMechanics(swept)) {
341
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}${m}`);
342
+ }
343
+ }
344
+ }
327
345
  // THE COACHING HAS TO MATCH THE ROOM YOU ARE IN. Inside a host,
328
346
  // `go` is refused (go.ts checkHostBoundary) and `map` draws the
329
347
  // outside grid you cannot reach -- so offering both is the same
@@ -313,10 +313,28 @@ export class SayCommand extends Command {
313
313
  // presence on the grid and is not -- "call" is still the verb that
314
314
  // reaches a body.
315
315
  if (currentPlayer.plane === 'matrix' && !(this.actor instanceof NPC)) {
316
- const heard = speakOnTheGrid(this.scene, currentPlayer, '').map(a => a.name);
317
- return heard.length > 0
318
- ? `Your persona speaks and the grid carries it: ${heard.join(', ')} ${heard.length === 1 ? 'is' : 'are'} on this side of the architecture and hears you. Bodies in the room hear nothing -- you have no voice out there.`
319
- : `Your words go out over the grid and find no persona to land on. Anyone here in the FLESH hears nothing -- a PAN icon is a commlink in somebody's pocket, not a person you can talk to. ("call <name>" reaches a body.)`;
316
+ const { personas, links } = speakOnTheGrid(this.scene, currentPlayer, '');
317
+ const iconNames = personas.map(a => a.name);
318
+ const linkNames = links.map(a => a.name);
319
+ const lines = [];
320
+ if (iconNames.length > 0) {
321
+ lines.push(`Your persona speaks and the grid carries it: ${iconNames.join(', ')} ${iconNames.length === 1 ? 'is' : 'are'} on this side of the architecture and hears you.`);
322
+ }
323
+ // A COMMLINK IS FOR TALKING (FhXadizkMCxAdMaJx). This used to be
324
+ // the refusal -- "a PAN icon is a commlink in somebody's pocket,
325
+ // not a person you can talk to" -- which had the fact right and
326
+ // drew the wrong conclusion. It IS a commlink in their pocket, and
327
+ // that is precisely a thing you can speak out of.
328
+ if (linkNames.length > 0) {
329
+ lines.push(`It comes out of ${linkNames.join(', ')}'s commlink where ${linkNames.length === 1 ? 'they stand' : 'they stand'} in the flesh -- your voice out of ${linkNames.length === 1 ? 'their' : 'their'} pocket, not out of the air.`);
330
+ }
331
+ if (lines.length === 0) {
332
+ return `Your words go out over the grid and land on nothing. No persona on this side of the architecture, and nobody near your body is carrying a live commlink for them to come out of. ("call <name>" reaches somebody further off.)`;
333
+ }
334
+ if (linkNames.length === 0) {
335
+ lines.push(`Bodies in the room hear nothing -- nobody there is carrying a live link.`);
336
+ }
337
+ return lines.join('\n');
320
338
  }
321
339
  return farOff;
322
340
  }
@@ -52,10 +52,21 @@ export class ShoutCommand extends Command {
52
52
  // each other or they do not, and no amount of volume crosses a
53
53
  // host wall. So this routes to the same fan-out `say` uses rather
54
54
  // than inventing a louder one.
55
- const heard = speakOnTheGrid(this.scene, actor, message).map(a => a.name);
56
- return heard.length > 0
57
- ? `You push it out across the grid: "${message}"\n Heard by ${heard.join(', ')}.`
58
- : `You push it out across the grid -- nothing out here is listening.`;
55
+ // Both halves of the fan-out, named apart: a persona beside you and
56
+ // a commlink in somebody's pocket are two different places for your
57
+ // voice to come out of, and only one of them is standing in the
58
+ // same room as your body (FhXadizkMCxAdMaJx).
59
+ const { personas, links } = speakOnTheGrid(this.scene, actor, message);
60
+ const heard = personas.map(a => a.name);
61
+ const onLink = links.map(a => a.name);
62
+ if (heard.length === 0 && onLink.length === 0) {
63
+ return `You push it out across the grid -- nothing out here is listening.`;
64
+ }
65
+ const who = [
66
+ ...(heard.length > 0 ? [`Heard by ${heard.join(', ')}.`] : []),
67
+ ...(onLink.length > 0 ? [`Out of ${onLink.join(', ')}'s commlink, where they stand in the flesh.`] : []),
68
+ ];
69
+ return `You push it out across the grid: "${message}"\n ${who.join('\n ')}`;
59
70
  }
60
71
  const heard = actor.currentLocation.shout(actor, message);
61
72
  if (!(this.actor instanceof NPC)) {
@@ -1205,5 +1205,79 @@
1205
1205
  // other at all. The meat case is pinned in the same test file, because
1206
1206
  // a fix that traded this report for the older one it was built from
1207
1207
  // would be no fix.
1208
- export const ENGINE_VERSION = '1.78.0';
1208
+ // 1.79.0 (2026-09-16): A COMMLINK IS FOR TALKING (FhXadizkMCxAdMaJx,
1209
+ // Maka, rejecting the first fix: "this looks like it's backwards. If I
1210
+ // jack in, and Grunt's icon is in reach, he SHOULD hear me through his
1211
+ // icon when I do a 'say'"). He is right and the books agree. p.246
1212
+ // names this as the exception to the host wall itself -- from outside
1213
+ // a host "you can't interact directly with icons inside it, ALTHOUGH
1214
+ // you can still send messages, make commcalls, and that sort of thing"
1215
+ // -- and nothing anywhere requires the person on the other end to be
1216
+ // jacked in. The old answer, "a PAN icon is a commlink in somebody's
1217
+ // pocket, not a person you can talk to", had the fact right and drew
1218
+ // the wrong conclusion from it: a commlink in somebody's pocket is
1219
+ // exactly a thing a voice can come out of.
1220
+ // - `say` and `shout` from the grid now reach the PERSONAS beside you
1221
+ // (as always) AND the live commlinks of people standing around your
1222
+ // BODY, delivered with hear()'s existing viaLink so the NPC knows it
1223
+ // came out of their pocket rather than from a mouth in the room.
1224
+ // - SCOPED TO THE VICINITY, not the district. Canon would let a
1225
+ // persona call a commlink anywhere on the grid -- distance is noise,
1226
+ // not range -- but a `say` that rang every commlink in the barrens
1227
+ // is a broadcast nobody asked for. "In reach" means what it means in
1228
+ // the icon list the reporter was reading: the room the body is in.
1229
+ // `call <name>` still reaches further.
1230
+ // - isLinked is the test, the same predicate that decides whether a
1231
+ // body is a ▣ icon at all, so the icon list and the voice agree: no
1232
+ // link, no icon, no pocket for the words to come out of -- and the
1233
+ // refusal says which of the two reasons it was.
1234
+ // The old test asserted the right refusal for the WRONG REASON: its
1235
+ // comment claimed "Krow is HERE, in the flesh, with a PAN icon on the
1236
+ // grid" while its fixture gave him no commlink at all, so there was
1237
+ // never a PAN icon in it. Pinned properly now, both ways.
1238
+ // 1.80.0 (2026-09-16): SECURITY ICONS ARE DELIBERATELY HIDDEN
1239
+ // (eJANPpm7qCaCAJBJw). The reporter rejected the last pass with "double
1240
+ // check canon again", then quoted p.217 in full, and the full sentence
1241
+ // is the whole design:
1242
+ // "some icons are deliberately hidden from view, such as locks and
1243
+ // other security devices, baby monitors, maintenance monitors, and
1244
+ // of course PEOPLE WHO PREFER NOT TO BE SEEN."
1245
+ // Two things fall out of that list. It is ONE list -- the maglock and
1246
+ // the runner who went dark in the same breath -- and the last clause
1247
+ // identifies the mechanic: "people who prefer not to be seen" is
1248
+ // RUNNING SILENT (p.235-236), the only hiding the rules define. So
1249
+ // nothing here is invented; the sentence points at a rule that existed.
1250
+ // - Device.silent, true by default for the security kinds ('lock',
1251
+ // 'panel' -- the maglock, the keypad, the safe). A ward is the Art
1252
+ // and has no wireless mode to hide in.
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.
1257
+ // - AND PEOPLE. Going dark used to make an icon simply unfindable:
1258
+ // isLinked answers false and nothing anywhere rolled to beat it, so
1259
+ // the book's own example of a hidden icon was the one case with no
1260
+ // way through. Player.matrixSpottedBy closes it.
1261
+ // - THE SWEEP is utilities/silent-icons.ts, fired by `look` on the grid
1262
+ // -- because `look` there IS Matrix Perception (p.241) and is already
1263
+ // billed as the Complex Action it is. A second look is a second
1264
+ // action, which is what makes a dark room cost something. Ties go to
1265
+ // the hider (p.236). Per viewer: one decker spotting an icon does not
1266
+ // hand it to the team.
1267
+ // - A MISS STILL REPORTS SOMETHING IS THERE, which is step one of the
1268
+ // canon two-step: a hit buys "there are icons running silent in the
1269
+ // vicinity" (p.235-236). A failed sweep is not silence -- it is the
1270
+ // difference between a hidden lock and a door with no lock on it.
1271
+ // - THE ICON HIDES; THE OBJECT DOES NOT. This is a Matrix fact and
1272
+ // nothing else. The maglock is still screwed to the door, still
1273
+ // described by a meat-world look, still picked, breached, shot and
1274
+ // keyed exactly as before. Only the grid view, the AR overlay,
1275
+ // Matrix-side Tab and Matrix-side targeting ask the question -- and
1276
+ // the AR overlay matters: hide the icon on the grid alone and the
1277
+ // street overlay quietly becomes a free Matrix Perception.
1278
+ // NOT INVENTED, deliberately: the -2 a silent icon pays is left on what
1279
+ // it DOES (p.235-236, already charged to players through
1280
+ // matrixActionPenalty) rather than added to its hide roll. Spending it
1281
+ // twice would be a house rule wearing a citation.
1282
+ export const ENGINE_VERSION = '1.80.0';
1209
1283
  //# sourceMappingURL=engine-version.js.map
@@ -16,6 +16,15 @@ export const DEFAULT_DEVICE_RATING = {
16
16
  lock: 2,
17
17
  panel: 2,
18
18
  };
19
+ /**
20
+ * WHICH KINDS ARE BORN DARK (p.217-218: "some icons are deliberately
21
+ * hidden from view, such as locks and other security devices"; user
22
+ * ruling 2026-09-16 on what that covers -- "cameras, locks, sensors
23
+ * etc"). In this engine a 'lock' and a 'panel' ARE the security
24
+ * furniture: the maglock, the keypad, the safe. A 'ward' is the Art and
25
+ * has no wireless mode to hide in.
26
+ */
27
+ export const SILENT_BY_DEFAULT = new Set(['lock', 'panel']);
19
28
  /**
20
29
  * THE BOOK'S BARRIER TIERS (p.197 Barrier Ratings, RAG-checked 2026-09-03):
21
30
  * Structure / Armor. Fragile is standard glass; Reinforced is a security
@@ -116,6 +125,56 @@ export class Device {
116
125
  */
117
126
  rating;
118
127
  wireless;
128
+ /**
129
+ * RUNNING SILENT (p.235-236), and why a maglock is born that way
130
+ * (eJANPpm7qCaCAJBJw; user ruling 2026-09-16: security devices means
131
+ * "cameras, locks, sensors etc").
132
+ *
133
+ * p.217-218: "some icons are deliberately hidden from view, such as
134
+ * locks and other security devices." The books do not give that
135
+ * sentence a mechanic of its own, and RUNNING SILENT is the only
136
+ * hiding the rules define -- a state an owner CHOOSES, which is
137
+ * exactly what a site does with its own security. Splintered State
138
+ * p.47-50 runs the same play with a whole host ("the entire Security
139
+ * Host is running silent... a character must succeed a Matrix
140
+ * Perception test... the Security Host receives a -2 dice pool penalty
141
+ * to all actions due to running silent"), so this is published
142
+ * practice rather than an invention.
143
+ *
144
+ * IT IS A TRADE, NOT A WALL. Silent costs the device -2 on everything
145
+ * it does (see deviceDefensePool), so a lock that is hard to FIND is
146
+ * easier to crack once found. That is the whole bargain, and it is why
147
+ * this is not simply "security devices are invisible".
148
+ *
149
+ * THE ICON HIDES; THE OBJECT DOES NOT. This is a Matrix fact and
150
+ * nothing else. The maglock is still screwed to the door, still
151
+ * described by a meat-world `look`, still picked, breached, shot and
152
+ * opened with a key exactly as before -- bypass-command.ts and the
153
+ * spot system never ask this question. Only the grid view, the AR
154
+ * overlay and Matrix-side targeting do.
155
+ */
156
+ silent;
157
+ /**
158
+ * WHO HAS SPOTTED IT, by name -- the other half of running silent.
159
+ *
160
+ * Session-scoped and per VIEWER, the same shape as Room.revealedBy and
161
+ * Host.spotted, and for the same reason: one decker beating the
162
+ * opposed test does not hand the icon to everybody on the team. Once
163
+ * found, it stays found -- a silent icon you have already pinned does
164
+ * not go missing again between looks.
165
+ */
166
+ 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) {
170
+ if (!this.broadcastsAro())
171
+ return false;
172
+ return !this.silent || this.spottedBy.has(viewerName);
173
+ }
174
+ /** Hidden from this viewer, and therefore worth rolling against. */
175
+ isHiddenFrom(viewerName) {
176
+ return this.broadcastsAro() && this.silent && !this.spottedBy.has(viewerName);
177
+ }
119
178
  /**
120
179
  * WHAT IT IS MADE OF (p.197-198): Structure is the condition monitor
121
180
  * shooting it fills, Armor is what it soaks with. Reaching Structure
@@ -166,6 +225,12 @@ export class Device {
166
225
  // because a seeded keypad nobody thought about is a keypad on the
167
226
  // grid -- which is the state the report says the game should model.
168
227
  this.wireless = config.kind === 'ward' ? false : (config.wireless ?? true);
228
+ // SECURITY IS SILENT BY DEFAULT (p.217-218: "locks and other
229
+ // security devices"). In this engine a 'lock' and a 'panel' ARE the
230
+ // security furniture -- the maglock, the keypad, the safe -- so both
231
+ // are born dark unless a seed says otherwise. A 'ward' has no
232
+ // wireless mode to hide in the first place.
233
+ this.silent = config.kind === 'ward' ? false : (config.silent ?? SILENT_BY_DEFAULT.has(config.kind));
169
234
  }
170
235
  isOpen() {
171
236
  return this.open;
@@ -150,6 +150,22 @@ export class Player extends AbstractPlayer {
150
150
  // but stops broadcasting -- no calls out, and nobody can raise this
151
151
  // actor (call.ts checks both ends). Toggling it drops any active call.
152
152
  runningSilent = false;
153
+ /**
154
+ * WHO HAS PICKED THIS ICON OUT OF THE NOISE, by name.
155
+ *
156
+ * The other half of running silent, and the half that was missing:
157
+ * going dark used to make an icon simply unfindable, because isLinked
158
+ * answers false and nothing anywhere rolled to beat it. p.217 lists
159
+ * "people who prefer not to be seen" beside the locks and the
160
+ * maintenance monitors, and p.236 gives the way through all of them --
161
+ * an Opposed Computer + Intuition [Data Processing] v. Logic + Sleaze.
162
+ * utilities/silent-icons.ts rolls it; this remembers who won.
163
+ *
164
+ * Session-scoped and per VIEWER, the same shape as Device.spottedBy,
165
+ * Room.revealedBy and Host.spotted: one decker beating the test does
166
+ * not hand the icon to the whole team.
167
+ */
168
+ matrixSpottedBy = new Set();
153
169
  // Whoever this actor most recently traded combat exchanges with, and
154
170
  // when -- set for BOTH sides by CombatExchange.beginExchange(). Walking
155
171
  // out on a live, recent opponent invites a parting shot (see go.ts);
@@ -59,6 +59,21 @@ export class Room extends AbstractRoom {
59
59
  // searchedBy/visitedBy (fresh Rooms per scene), and per-VIEWER: one
60
60
  // runner spotting the sniper doesn't hand them to everyone.
61
61
  revealedBy = new Map();
62
+ /**
63
+ * WHO HAS SPOTTED THE CAMERAS, by name (eJANPpm7qCaCAJBJw; p.217 names
64
+ * cameras first in its list of icons "deliberately hidden from view").
65
+ *
66
+ * The camera cluster is not a Device -- it is derived from the room
67
+ * itself (utilities/surveillance.ts isWatched), so it cannot carry
68
+ * Device.spottedBy and needs its own set. Same contract as that one:
69
+ * session-scoped, per VIEWER, and once found it stays found.
70
+ *
71
+ * The LENSES are not hidden, only their icon. A body in the room still
72
+ * sees cameras on the wall with its eyes and can still "tap" one in
73
+ * the flesh; what takes a Matrix Perception is picking their icon out
74
+ * of the grid.
75
+ */
76
+ camerasSpottedBy = new Set();
62
77
  /**
63
78
  * THE FALLEN (player ruling 2026-08-25). A killed actor leaves a BODY
64
79
  * where they dropped, holding everything they carried, instead of
@@ -140,7 +140,9 @@ export function aroTags(scene, actor, room) {
140
140
  }
141
141
  // THE EYES. Whether they are looped is knowledge you had to take from
142
142
  // the host, so it shows only to whoever owns the feeds.
143
- if (isWatched(room)) {
143
+ // Same gate as the grid: this overlay draws ICONS, and a camera icon
144
+ // is the first thing p.217 lists as deliberately hidden until spotted.
145
+ if (isWatched(room) && room.camerasSpottedBy.has(actor.name)) {
144
146
  const rooms = scene.getRooms();
145
147
  // The verb rides the tag: standing in the room IS the requirement
146
148
  // for a direct connection (commands/tap.ts), so this is the one
@@ -165,7 +167,12 @@ export function aroTags(scene, actor, room) {
165
167
  // The RATING RIDES THE TAG for the same reason the host's [HR] does --
166
168
  // it is the number you decide against before touching anything.
167
169
  for (const device of room.devices) {
168
- if (!device.broadcastsAro())
170
+ // The same gate as the grid view. This overlay draws ICONS, and an
171
+ // icon running silent is not one of them until it has been spotted
172
+ // (p.217, p.235-236). The OBJECT is untouched: the maglock is still
173
+ // screwed to the door, still described by a meat-world `look`, and
174
+ // still picked, breached, shot and keyed exactly as before.
175
+ if (!device.hasIconFor(actor.name))
169
176
  continue;
170
177
  tags.push(`▤ ${device.name} -- wireless, {bold}[DR ${device.rating}]{/bold}${device.isOpen() ? ', standing open' : `.${hint(` ("${device.verbs()[0]} ${device.name}")`)}`}`);
171
178
  }
@@ -256,13 +256,23 @@ export function gridIcons(scene, actor, room) {
256
256
  // device has an icon, it looks like the object, and it carries a
257
257
  // Device Rating (p.219, p.234).
258
258
  for (const device of room.devices) {
259
- if (!device.broadcastsAro())
259
+ // HIDDEN ICONS ARE HIDDEN (p.217, eJANPpm7qCaCAJBJw). A silent lock
260
+ // is not on the grid until THIS viewer has beaten it in an opposed
261
+ // Matrix Perception (utilities/silent-icons.ts) -- per viewer,
262
+ // because one decker spotting it does not hand it to the team.
263
+ if (!device.hasIconFor(actor.name))
260
264
  continue;
261
265
  icons.push(deviceIconLine(device, actor));
262
266
  }
263
267
  // The cameras, as a THING (surveillance.ts): live for security, looped,
264
268
  // or yours to ride.
265
- if (isWatched(room)) {
269
+ //
270
+ // p.217 NAMES CAMERAS FIRST among the icons "deliberately hidden from
271
+ // view" (eJANPpm7qCaCAJBJw). The lenses are still bolted to the wall,
272
+ // still described in the meat, and a body still "tap"s one in the
273
+ // flesh -- it is the ICON that takes a Matrix Perception to pick out
274
+ // of the noise (utilities/silent-icons.ts).
275
+ if (isWatched(room) && room.camerasSpottedBy.has(actor.name)) {
266
276
  const rooms = scene.getRooms();
267
277
  if (!camerasLive(rooms, room)) {
268
278
  icons.push(`◎ Camera cluster -- looped on empty hallways. Security sees NOTHING here.${canSnoopFeeds(rooms, room, actor) ? ` ("snoop" rides the feeds you own)` : ''}`);
@@ -1,5 +1,6 @@
1
1
  import { roomsInReach } from './grid-reach.js';
2
2
  import { NPC } from '../models/npc.js';
3
+ import { isLinked } from './comm-style.js';
3
4
  /**
4
5
  * WHO A PERSONA CAN REACH (increment 2 of the Matrix position model,
5
6
  * 2026-09-02). The persona's position is `Player.matrixPosition` -- on a
@@ -35,13 +36,62 @@ export function cybercombatCandidates(scene, actor) {
35
36
  export function speechAudience(scene, actor) {
36
37
  return personasOnMySide(scene, actor);
37
38
  }
38
- /** Delivers a persona's words to its audience, the way Room.say delivers a body's. */
39
+ /**
40
+ * THE COMMLINKS IN EARSHOT OF YOUR BODY (FhXadizkMCxAdMaJx, Maka: "this
41
+ * looks like it's backwards. If I jack in, and Grunt's icon is in reach,
42
+ * he SHOULD hear me through his icon when I do a 'say'").
43
+ *
44
+ * He is right, and the books agree. A commlink is a COMMUNICATION
45
+ * device: p.246 says that even across a host wall "you can still send
46
+ * messages, make commcalls, and that sort of thing", and nothing
47
+ * anywhere requires the person on the other end to be jacked in -- a
48
+ * commcall rings a commlink in somebody's pocket, which is what a
49
+ * commlink is for. The engine's old answer, "a PAN icon is a commlink in
50
+ * somebody's pocket, not a person you can talk to", had the fact right
51
+ * and drew the wrong conclusion from it.
52
+ *
53
+ * SCOPED TO THE VICINITY, not the district. Canon would let a persona
54
+ * call a commlink anywhere on the grid -- distance is noise, not range
55
+ * -- but `say` is the verb for speaking to the people around you, and a
56
+ * `say` that rang every commlink in the barrens would be a broadcast
57
+ * nobody asked for. The room your body is slumped in is what "in reach"
58
+ * means in the icon list the reporter was reading, so it is what it
59
+ * means here. `call <name>` is still the verb for reaching somebody
60
+ * further off, and it still works.
61
+ *
62
+ * A PAN IS THE TEST, not a pulse: isLinked is the same predicate that
63
+ * decides whether a body shows up as a ▣ icon at all. Somebody with no
64
+ * commlink, or running silent, has nothing for your voice to come out
65
+ * of -- and does not appear in the icon list either, so the two answers
66
+ * agree.
67
+ */
68
+ export function panAudience(scene, actor) {
69
+ const here = actor.bodyRoom ?? actor.currentLocation;
70
+ if (!here)
71
+ return [];
72
+ return scene.allActors.filter(a => a !== actor
73
+ && a.plane === 'meat'
74
+ && (a.bodyRoom ?? a.currentLocation) === here
75
+ && isLinked(a));
76
+ }
77
+ /**
78
+ * Delivers a persona's words to its audience, the way Room.say delivers
79
+ * a body's -- to the personas beside it, and out of the commlinks of the
80
+ * people standing around its body.
81
+ */
39
82
  export function speakOnTheGrid(scene, actor, message) {
40
- const audience = speechAudience(scene, actor);
41
- for (const recipient of audience) {
83
+ const personas = speechAudience(scene, actor);
84
+ for (const recipient of personas) {
42
85
  void Promise.resolve(recipient.hear(actor, message, { broadcast: true })).catch(() => undefined);
43
86
  }
44
- return audience;
87
+ // viaLink, so the NPC knows this arrived out of their commlink rather
88
+ // than from a mouth in the room -- which is the whole difference, and
89
+ // the thing their reply has to be able to react to.
90
+ const links = panAudience(scene, actor);
91
+ for (const recipient of links) {
92
+ void Promise.resolve(recipient.hear(actor, message, { broadcast: true, viaLink: true })).catch(() => undefined);
93
+ }
94
+ return { personas, links };
45
95
  }
46
96
  /**
47
97
  * The combat exchange's "same place": on the matrix plane it is the
@@ -36,6 +36,11 @@ function matrixNameables(scene, actor, rooms) {
36
36
  push(out, seen, { glyph: '▣', name: body.name, what: 'a body in reach -- its PAN' });
37
37
  }
38
38
  for (const device of room.openableDevices()) {
39
+ // Tab must not hand you the name of an icon you have not spotted
40
+ // (p.217) -- that is the same "Tab is a free search" defect
41
+ // yaw2btNX8aNEEF9D3 fixed for meat-world loot, one plane over.
42
+ if (!device.hasIconFor(actor.name))
43
+ continue;
39
44
  push(out, seen, { glyph: '▤', name: device.name, what: `device, rating ${device.rating}` });
40
45
  }
41
46
  }
@@ -0,0 +1,137 @@
1
+ import { rollPool, formatRoll } from './dice.js';
2
+ import { deviceDefensePool } from './matrix-intrusion.js';
3
+ import { isWatched, cameraMasterHost } from './surveillance.js';
4
+ import { DEFAULT_DEVICE_RATING } from '../models/device.js';
5
+ function hiddenDevice(device) {
6
+ return {
7
+ name: device.name,
8
+ // Its rating stands in for the Mental attribute it has not got
9
+ // (p.237-238) -- the same pool it defends an intrusion with.
10
+ hide: { pool: deviceDefensePool(device).pool, label: 'Device Rating (no Logic to hide behind)' },
11
+ reveal: (viewer) => { device.spottedBy.add(viewer); },
12
+ };
13
+ }
14
+ function hiddenPersona(actor) {
15
+ return {
16
+ name: actor.name,
17
+ // p.236/p.241, verbatim: "the target defending with Logic + Sleaze".
18
+ hide: { pool: Math.max(0, actor.logic + actor.matrixAttribute('sleaze')), label: 'Logic + Sleaze' },
19
+ reveal: (viewer) => { actor.matrixSpottedBy.add(viewer); },
20
+ };
21
+ }
22
+ /**
23
+ * Everything running silent within reach of this viewer that they have
24
+ * not already pinned.
25
+ *
26
+ * DEVICES come from the room the sweep is aimed at. PEOPLE come from the
27
+ * scene, because a runner who went dark is still standing somewhere, and
28
+ * the icon you are looking for is their PAN -- which sits where their
29
+ * body does.
30
+ */
31
+ export function hiddenIconsIn(scene, room, viewer) {
32
+ const out = room.devices
33
+ .filter(d => d.isHiddenFrom(viewer.name))
34
+ .map(hiddenDevice);
35
+ // THE CAMERAS, which p.217 names FIRST. They are not a Device -- the
36
+ // cluster is derived from the room (surveillance.ts isWatched) -- so
37
+ // they hide on the room's own set and resist with their master host's
38
+ // rating where there is one: a site's lenses are as quiet as the
39
+ // security that runs them, and a slave borrows its master's numbers
40
+ // (p.233). Without a host they fall back to the door-lock rating
41
+ // canon prices ordinary fixtures at (p.356).
42
+ if (isWatched(room) && !room.camerasSpottedBy.has(viewer.name)) {
43
+ const master = scene ? cameraMasterHost(scene.getRooms(), room) : undefined;
44
+ const rating = master?.hostRating ?? DEFAULT_DEVICE_RATING.lock ?? 2;
45
+ out.push({
46
+ name: 'Camera cluster',
47
+ hide: { pool: Math.max(0, rating * 2), label: master ? 'its host\'s rating' : 'Device Rating' },
48
+ reveal: (v) => { room.camerasSpottedBy.add(v); },
49
+ });
50
+ }
51
+ for (const a of scene?.allActors ?? []) {
52
+ if (a === viewer)
53
+ continue;
54
+ if (!a.runningSilent)
55
+ continue;
56
+ if (a.matrixSpottedBy.has(viewer.name))
57
+ continue;
58
+ // Where the BODY is, because that is where the commlink is.
59
+ if ((a.bodyRoom ?? a.currentLocation) !== room)
60
+ continue;
61
+ out.push(hiddenPersona(a));
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ * ONE MATRIX PERCEPTION, AGAINST EVERYTHING HIDING IN THE ROOM.
67
+ *
68
+ * Canon resolves one icon per action. This rolls the sweeper's pool ONCE
69
+ * and compares it against each hider's own resistance, which is a
70
+ * deliberate simplification worth naming: rolling a fresh attacker pool
71
+ * per icon would make a room with six locks six times as easy to sweep
72
+ * for the same single action, and asking a player to NAME an icon they
73
+ * cannot see yet is not a question anybody can answer. One action, one
74
+ * roll; the icons differ by what they hide behind.
75
+ *
76
+ * `look` is where it fires, because `look` on the grid IS Matrix
77
+ * Perception (p.241) and is already billed as the Complex Action it is.
78
+ * A second look is a second action, which is what makes a dark room
79
+ * cost something.
80
+ */
81
+ export function spotHiddenIcons(scene, actor, room) {
82
+ const hidden = hiddenIconsIn(scene, room, actor);
83
+ if (hidden.length === 0)
84
+ return undefined;
85
+ // Computer + Intuition [Data Processing] (p.241), carrying the
86
+ // sweeper's own silence, wounds and sustaining the way every other
87
+ // Matrix action in this engine does.
88
+ const computer = actor.skillRating('computer');
89
+ const pool = Math.max(1, actor.intuition + (computer > 0 ? computer : -1)
90
+ + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
91
+ const roll = rollPool(pool, actor.matrixAttribute('dataProcessing'), { gremlins: actor.deckGremlins });
92
+ const found = [];
93
+ const missed = [];
94
+ const against = [];
95
+ for (const icon of hidden) {
96
+ const resist = rollPool(icon.hide.pool);
97
+ against.push({ name: icon.name, label: icon.hide.label, roll: resist });
98
+ // Ties go to the hider: "on a tie or more hits by the defender, it
99
+ // stays hidden and out of reach" (p.236).
100
+ if (roll.hits > resist.hits) {
101
+ icon.reveal(actor.name);
102
+ found.push(icon.name);
103
+ }
104
+ else {
105
+ missed.push(icon.name);
106
+ }
107
+ }
108
+ return { found, missed, roll, against };
109
+ }
110
+ /** The mechanics lines for a sweep, for a screen that shows them. */
111
+ export function spotMechanics(outcome) {
112
+ return [
113
+ `Matrix Perception, silent icons (Computer + Intuition [Data Processing]): ${formatRoll(outcome.roll)}`,
114
+ ...outcome.against.map(a => ` ${a.name} hides (${a.label}): ${formatRoll(a.roll)}`),
115
+ ];
116
+ }
117
+ /**
118
+ * What the runner is TOLD, which is never nothing.
119
+ *
120
+ * A miss still reports that something is there -- that is step 1 above,
121
+ * the hit that buys "there are icons running silent in the vicinity",
122
+ * and it is the whole reason a hidden lock reads differently from a door
123
+ * with no lock on it.
124
+ */
125
+ export function spotLine(outcome) {
126
+ const lines = [];
127
+ if (outcome.found.length > 0) {
128
+ 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.`);
129
+ }
130
+ if (outcome.missed.length > 0) {
131
+ lines.push(outcome.missed.length === 1
132
+ ? `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.)`
133
+ : `${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.)`);
134
+ }
135
+ return lines.join('\n');
136
+ }
137
+ //# sourceMappingURL=silent-icons.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.219.0",
3
+ "version": "5.221.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.",