@maka/maka-cli 5.200.0 → 5.202.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +10 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +2 -2
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +7 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +7 -6
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +10 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +20 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +237 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +20 -11
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +3 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +3 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +3 -2
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +15 -5
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/overwatch.js +3 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +36 -10
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/sprite-power.js +85 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +12 -3
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +3 -2
  19. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +165 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js +37 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +109 -11
  22. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +41 -9
  23. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +10 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/game.js +95 -3
  25. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +38 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +20 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +44 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +11 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +23 -3
  31. package/bundle/typescript/src/commands/game/sideQuest/npc-kits.js +295 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.js +48 -7
  33. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +34 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-errand.js +67 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-hire.js +113 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +87 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/commerce.js +5 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/utilities/drone-prose.js +11 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/utilities/ephemeral.js +17 -3
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +42 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +10 -5
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +37 -10
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +12 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +22 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/utilities/mechanics-audience.js +71 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +36 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +11 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +7 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +13 -3
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/sprite-power-actions.js +270 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/street-gang.js +25 -13
  52. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP, rollPool, formatRoll } from './dice.js';
2
2
  import { ActionBudget } from './action-budget.js';
3
3
  import { runNpcActionPhase } from './npc-combat-brain.js';
4
+ import { groupEdgeFor } from '../npc-kits.js';
4
5
  export function arenaKey(a) {
5
6
  return a.kind === 'room' ? `room:${a.room.name}` : a.kind === 'host' ? `host:${a.host.name}` : 'grid:open';
6
7
  }
@@ -114,6 +115,20 @@ export class CombatEncounter {
114
115
  pairs = new Map();
115
116
  surrenderSeenAt = new Map();
116
117
  running = false;
118
+ /**
119
+ * GRUNT TEAMS BY KIT (p.379-380, npc-kits.ts). Every kitted NPC who
120
+ * enters the fight is rostered under its kit slug, and the roster is
121
+ * the denominator morale reads -- a team that started four and lost
122
+ * two has lost half, whether or not the dead are still actors. Group
123
+ * Edge is a pool per kit, seeded to the PR on first sight (+1 with a
124
+ * lieutenant, p.380-381) and spent by the brain one point per grunt
125
+ * per fight. Keyed by KIT rather than by side because one scene can
126
+ * field a PR 1 gang and a PR 3 patrol, and the book prices them
127
+ * separately.
128
+ */
129
+ kitRoster = new Map();
130
+ groupEdge = new Map();
131
+ edgeSpentBy = new Set();
117
132
  /** Every human phase that began -- a seam for tests and for any future
118
133
  * phase clock; nothing plays the phase for a human (the fight
119
134
  * autopilot was retired 2026-09-07: a phase you commit with "end
@@ -213,8 +228,80 @@ export class CombatEncounter {
213
228
  acted: false, delayed: false, surprised: false,
214
229
  };
215
230
  this.participants.push(p);
231
+ this.rosterGrunt(actor);
216
232
  return p;
217
233
  }
234
+ /** Put a kitted NPC on its team's roster; seed the team's Group Edge on first sight. */
235
+ rosterGrunt(actor) {
236
+ const npc = actor;
237
+ if (!this.isNpc(actor) || npc.kit === undefined || npc.professionalRating === undefined)
238
+ return;
239
+ let roster = this.kitRoster.get(npc.kit);
240
+ if (!roster) {
241
+ roster = new Set();
242
+ this.kitRoster.set(npc.kit, roster);
243
+ }
244
+ roster.add(npc.name);
245
+ // p.380: "a team of grunts has a Group Edge equal to its Professional
246
+ // Rating". p.380-381: a lieutenant "adds 1 to their Edge". Seeded once;
247
+ // a lieutenant who joins late tops it up by their one point.
248
+ if (!this.groupEdge.has(npc.kit)) {
249
+ this.groupEdge.set(npc.kit, groupEdgeFor(npc.professionalRating));
250
+ }
251
+ if (npc.lieutenant)
252
+ this.groupEdge.set(npc.kit, (this.groupEdge.get(npc.kit) ?? 0) + 1);
253
+ }
254
+ /**
255
+ * THE STATE OF A GRUNT'S TEAM, for the brain's morale check. Casualties
256
+ * are rostered names that are dead (gone from the scene), incapacitated
257
+ * or death-claimed; a grunt who surrendered still stands and is not
258
+ * one. The lieutenant's +1 to PR lasts only while the lieutenant does.
259
+ */
260
+ teamOf(npc) {
261
+ if (npc.kit === undefined || npc.professionalRating === undefined)
262
+ return undefined;
263
+ const roster = this.kitRoster.get(npc.kit);
264
+ if (!roster)
265
+ return undefined;
266
+ let casualties = 0;
267
+ let lieutenantStands = false;
268
+ for (const name of roster) {
269
+ const member = this.scene.getActor(name);
270
+ const down = !member || member.isIncapacitated() || member.deathClaimed;
271
+ if (down)
272
+ casualties++;
273
+ else if (member.lieutenant)
274
+ lieutenantStands = true;
275
+ }
276
+ return {
277
+ kit: npc.kit,
278
+ total: roster.size,
279
+ casualties,
280
+ effectivePr: npc.professionalRating + (lieutenantStands ? 1 : 0),
281
+ edge: this.groupEdge.get(npc.kit) ?? 0,
282
+ };
283
+ }
284
+ /**
285
+ * SPEND A POINT OF GROUP EDGE ON THIS GRUNT (p.380: "the gamemaster
286
+ * can spend a point of Group Edge on any grunt on the team"). The
287
+ * engine is the gamemaster, and its standing policy is the plain one:
288
+ * one point per grunt per fight, on that grunt's first attack, while
289
+ * the pool lasts. Arms the same boost a runner's "edge" verb arms
290
+ * (Player.consumeEdgeBoost adds `edge` dice to the next strike), so
291
+ * the spend goes through the one lane every edge boost already uses.
292
+ */
293
+ spendGroupEdge(npc) {
294
+ if (npc.kit === undefined || npc.edgeBoostArmed || this.edgeSpentBy.has(npc.name))
295
+ return false;
296
+ const pool = this.groupEdge.get(npc.kit) ?? 0;
297
+ if (pool <= 0)
298
+ return false;
299
+ this.groupEdge.set(npc.kit, pool - 1);
300
+ this.edgeSpentBy.add(npc.name);
301
+ npc.edgeBoostArmed = true;
302
+ this.logger.write(`Encounter: Group Edge spent on ${npc.name} (${npc.kit}: ${pool - 1} left).`);
303
+ return true;
304
+ }
218
305
  /**
219
306
  * Someone walks into a running fight (p.160 Changing Initiative):
220
307
  * roll as normal, minus 10 per pass already gone this turn.
@@ -72,6 +72,11 @@ const roundTo5 = (n) => Math.max(5, Math.round(n / 5) * 5);
72
72
  * 120-nuyen credstick selling for its 25-nuyen shell prints money. */
73
73
  export function priceOf(item) {
74
74
  const cash = item.isCurrencyCarrier() ? item.currencyAmount : 0;
75
+ // Armor that stopped the rounds that killed its wearer is not a
76
+ // trade good (Room.layOutBody): a fence pays for the plates, and the
77
+ // plates are what is gone.
78
+ if (item.ruined)
79
+ return cash;
75
80
  if (typeof item.price === 'number' && item.price > 0)
76
81
  return Math.round(item.price) + cash;
77
82
  const base = CATEGORY_BASE[item.category] ?? 50;
@@ -246,7 +246,15 @@ export function droneReconNowhereLine(item) {
246
246
  missile: `The ${item.name} climbs, circles once -- nowhere to go from here.`,
247
247
  });
248
248
  }
249
- export function droneReconLaunchLine(item, rollText, ok) {
249
+ /**
250
+ * THE PROSE NO LONGER CARRIES THE DICE (wjFBY3zgyWzJP9adw sweep). This
251
+ * baked `Piloting: 8d6 -> 2 hits` into the world log while every other
252
+ * player test in the engine sends its anatomy to the Mechanics panel --
253
+ * the recon sweep was the one roll a rigger had to read out of a
254
+ * sentence. `rollText` is gone; commands/drone.ts emits the roll through
255
+ * logger.meta, and this line goes back to being prose.
256
+ */
257
+ export function droneReconLaunchLine(item, ok) {
250
258
  const off = pick(item, {
251
259
  'flier-palm': `The ${item.name} whirs off your palm`,
252
260
  flier: `The ${item.name} lifts off your hand`,
@@ -256,7 +264,7 @@ export function droneReconLaunchLine(item, rollText, ok) {
256
264
  missile: `The ${item.name} kicks off its rail`,
257
265
  });
258
266
  if (!ok)
259
- return `${off} -- Piloting: ${rollText}`;
267
+ return off;
260
268
  const threads = pick(item, {
261
269
  'flier-palm': 'threads the gaps',
262
270
  flier: 'threads the gaps',
@@ -265,7 +273,7 @@ export function droneReconLaunchLine(item, rollText, ok) {
265
273
  anthro: 'goes to look',
266
274
  missile: 'makes a fast pass',
267
275
  });
268
- return `${off} and ${threads} -- Piloting: ${rollText}. Feed incoming:`;
276
+ return `${off} and ${threads}. Feed incoming:`;
269
277
  }
270
278
  export function droneReconReturnLine(item) {
271
279
  return pick(item, {
@@ -3,6 +3,7 @@ import { Logger } from './logger.js';
3
3
  import { CommandRegistry } from '../commands/command-registry.js';
4
4
  import { actorCell, actorDistanceMeters, standingRoomNear, ADJACENT_METERS } from './spots.js';
5
5
  import { METERS_PER_CELL } from './room-grid.js';
6
+ import { outfitNpcBySlug, equipKitIntoHands } from '../npc-kits.js';
6
7
  /**
7
8
  * Bring one into the world. Returns the NPC, or null when the name is
8
9
  * already taken -- the same refusal spawnCompanionShell makes, and for
@@ -17,9 +18,11 @@ export function spawnEphemeral(host, opts) {
17
18
  const npc = new NPC({
18
19
  dialog: [],
19
20
  description: opts.description,
20
- playerConstructorConfig: { playerName: opts.name, startLocation: opts.room, combat: opts.combat },
21
+ playerConstructorConfig: { playerName: opts.name, startLocation: opts.room, combat: opts.combat ?? {} },
21
22
  }, new CommandRegistry(host.scene));
22
23
  npc.plane = 'meat';
24
+ if (outfitNpcBySlug(npc, opts.kit))
25
+ equipKitIntoHands(npc);
23
26
  // NOT allyOf, and NOT companionKind: an ephemeral belongs to nobody
24
27
  // and is on no roster. Those two fields are what make a companion
25
28
  // persist, follow, and take orders.
@@ -62,8 +65,19 @@ export function despawnEphemerals(host, opts) {
62
65
  : opts.playerRoomName !== undefined && opts.playerRoomName !== mark.roomName;
63
66
  if (!ended)
64
67
  continue;
65
- if (host.scene.getActor(name) instanceof NPC)
66
- host.scene.removeActor(name);
68
+ const body = host.scene.getActor(name);
69
+ // A DEPARTURE IS NOT A DEATH (2026-09-15, the day kits gave gangers
70
+ // a Browning and an armor vest). removeActor spills what an actor
71
+ // carried onto the floor, which is right for a corpse and wrong for
72
+ // a ganger who watched you walk off their corner: the street was
73
+ // raining pistols, per ganger, per block. A body that is DOWN when
74
+ // its scope ends spills, because it is a body; one that simply
75
+ // stops existing takes its gear with it. That is also the honest
76
+ // reading of this governor's founding denial -- an ephemeral
77
+ // cannot create obligations, and a gun the world must keep track
78
+ // of after its owner is gone is one.
79
+ if (body instanceof NPC)
80
+ host.scene.removeActor(name, { spill: body.isIncapacitated() });
67
81
  host.ephemerals.delete(name);
68
82
  removed.push(name);
69
83
  }
@@ -77,6 +77,48 @@ export function hostLabel(room, opts = {}) {
77
77
  const bare = room.name.replace(/^the\s+/i, '');
78
78
  return `${opts.capital ? 'The' : 'the'} ${bare} host`;
79
79
  }
80
+ /**
81
+ * WHAT THE HOST RUNS ON, ONCE YOU HAVE PAID FOR IT (np6YBSPsdfAY7NcuZ,
82
+ * Maka: "data like this ... shouldn't be visible without a matrix search
83
+ * on the host").
84
+ *
85
+ * `Host.purpose` is seed content and the generator is told to fill it
86
+ * with "what this place runs on its host and so what is worth protecting
87
+ * inside it" (factories/scene-chunks.ts) -- so in practice it enumerates
88
+ * the archive: "patient intake logs, med supply orders, and the doc's
89
+ * private black book of who paid what for what". Four renderers printed
90
+ * that on the host's OUTSIDE icon with no roll, which handed a passer-by
91
+ * the inventory of a sealed host for free. The file NAMES were already
92
+ * gated (grid-view.ts, Host.searchedBy); only this phrase leaked.
93
+ *
94
+ * TWO TIERS, and both are rolls the engine already makes
95
+ * (commands/search.ts matrixSearch):
96
+ *
97
+ * tier 1 Room.searchedBy -- the threshold-1 public row out on the
98
+ * grid (MATRIX_SEARCH.public). What the place runs on its host
99
+ * is the kind of thing a public search turns up: canon has the
100
+ * icon look like the shop (p.219). This set is also flipped by
101
+ * a MEAT search of the room, and that is intended -- casing the
102
+ * clinic in the flesh is a fair way to learn it keeps a ledger.
103
+ * tier 2 Host.searchedBy -- the threshold-3 archive search from
104
+ * inside, which additionally NAMES the files (grid-view.ts).
105
+ *
106
+ * A cracked host spills it free, exactly as its files already do: the
107
+ * seal is broken, there is nothing left to hide behind.
108
+ *
109
+ * Returns undefined when the seed declared no purpose OR the persona has
110
+ * not earned it, so every caller can `??` it away.
111
+ */
112
+ export function hostPurposeFor(room, actor) {
113
+ const host = room.host;
114
+ if (!host?.purpose)
115
+ return undefined;
116
+ if (host.cracked)
117
+ return host.purpose;
118
+ return host.searchedBy.has(actor.name) || room.searchedBy.has(actor.name)
119
+ ? host.purpose
120
+ : undefined;
121
+ }
80
122
  export function hostNameHint(room) {
81
123
  return room.hasNode ? `the host here answers to "host" or "${room.name}"` : '';
82
124
  }
@@ -1,5 +1,5 @@
1
1
  import { hostConcealedFrom } from './jackpoint.js';
2
- import { matchesHostName, hostLabel, hostAliases } from './grid-names.js';
2
+ import { matchesHostName, hostLabel, hostAliases, hostPurposeFor } from './grid-names.js';
3
3
  import { fuzzyPickName, normalizeLoose } from './fuzzy-match.js';
4
4
  import { MAX_MARKS } from './marks.js';
5
5
  /**
@@ -193,10 +193,15 @@ export function hostLine(room, actor) {
193
193
  // A haven shows no rating and no eyes (jackpoint.ts): it is not a
194
194
  // thing you crack, so the numbers you would crack it with stay off.
195
195
  const rating = room.hostSanctioned ? '' : ` [HR ${room.hostRating}]`;
196
- // The host's business, where the seed declared one (Host.purpose): a
197
- // grid map that tells a clinic's records from a club's door list is
198
- // the map that lets a runner pick a target.
199
- const purpose = room.host?.purpose ? ` (${room.host.purpose})` : '';
196
+ // The host's business, where the seed declared one AND this persona has
197
+ // searched for it (hostPurposeFor, np6YBSPsdfAY7NcuZ). A grid map that
198
+ // tells a clinic's records from a club's door list is the map that lets
199
+ // a runner pick a target -- but it is a Matrix Search that draws it,
200
+ // not a glance. Map rows stay terse when it is unearned: the verb is
201
+ // taught on the host's own icon (grid-view.ts gridIcons), not once per
202
+ // row of a map that may list a dozen.
203
+ const found = hostPurposeFor(room, actor);
204
+ const purpose = found ? ` (${found})` : '';
200
205
  return `${inside ? '▣' : '●'} ${hostLabel(room, { capital: true })}${rating}${purpose}${onGrid} -- ${standing}${alert}${over}${inside ? ' -- INSIDE' : ''}`;
201
206
  }
202
207
  /** The Map dock's one-line host: icon, name, rating, standing -- cut to the dock. */
@@ -2,7 +2,7 @@ import { sameHostSide } from '../models/player.js';
2
2
  import { personasInside } from './matrix-roster.js';
3
3
  import { hostsInReach, hostLine, roomsInReach, gridVicinity } from './grid-reach.js';
4
4
  import { NPC } from '../models/npc.js';
5
- import { hostLabel } from './grid-names.js';
5
+ import { hostLabel, hostPurposeFor } from './grid-names.js';
6
6
  import { isLinked } from './comm-style.js';
7
7
  import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
8
8
  /**
@@ -65,10 +65,13 @@ export function gridIcons(scene, actor, room) {
65
65
  // books never gave it -- boilerplate describing the opposite of what
66
66
  // the place is. A member is standing in a room they belong to.
67
67
  const hr = ` {bold}[HR ${room.hostRating}]{/bold}`;
68
- // WHAT IT IS FOR rides the outside icon too: a host's business is
69
- // public knowledge -- "your local Stuffer Shack has a host icon"
70
- // (p.219) and the icon looks like the shop. The inside stays sealed.
71
- const purpose = room.host?.purpose ? ` (${room.host.purpose})` : '';
68
+ // WHAT IT IS FOR rides the outside icon -- but only once a Matrix
69
+ // Search has turned it up (hostPurposeFor, np6YBSPsdfAY7NcuZ). The
70
+ // icon looking like the shop (p.219) is the NAME, which this line
71
+ // always prints; the seed's purpose phrase is an inventory of the
72
+ // archive, and handing that over for free is the report.
73
+ const found = hostPurposeFor(room, actor);
74
+ const purpose = found ? ` (${found})` : '';
72
75
  if (room.hostSanctioned) {
73
76
  icons.push(`● ${hostLabel(room, { capital: true })} -- three spheres turning over rooms that were never sold to anyone. You're on the list; nothing here is hunting.`);
74
77
  }
@@ -82,7 +85,18 @@ export function gridIcons(scene, actor, room) {
82
85
  // anyone inside it are on the far side of a wall (p.246). The
83
86
  // marks line stays because a mark is the DOOR (p.239), so it is
84
87
  // the one fact about the inside that belongs on the outside.
85
- icons.push(`● ${hostLabel(room, { capital: true })}${hr}${purpose} -- sculpted ice geometry, sealed. Whatever it holds is inside it.${room.hostAlert ? ' Walls UP -- it holds a mark on an intruder.' : ''}${myMarks > 0 ? ` [your marks: ${myMarks}/3]` : ''} (${myMarks > 0 ? '"enter" walks in on your mark; ' : ''}"hack" slips in on sleaze; "hack loud" smashes; "mark" softens it first)`);
88
+ // The verb that EARNS the purpose phrase is offered only while it
89
+ // is still unearned -- a host that has already told you its
90
+ // business has nothing left to sell. Built as a list so a dropped
91
+ // entry cannot leave a dangling separator.
92
+ const ways = [
93
+ ...(myMarks > 0 ? ['"enter" walks in on your mark'] : []),
94
+ '"hack" slips in on sleaze',
95
+ '"hack loud" smashes',
96
+ '"mark" softens it first',
97
+ ...(found ? [] : ['"search" reads what it runs (p.241)']),
98
+ ];
99
+ icons.push(`● ${hostLabel(room, { capital: true })}${hr}${purpose} -- sculpted ice geometry, sealed. Whatever it holds is inside it.${room.hostAlert ? ' Walls UP -- it holds a mark on an intruder.' : ''}${myMarks > 0 ? ` [your marks: ${myMarks}/3]` : ''} (${ways.join('; ')})`);
86
100
  }
87
101
  }
88
102
  // WHERE THE HOST'S OWN ICON ENDS AND THE STREET BEGINS.
@@ -457,7 +471,11 @@ export function gridSculpt(actor, room) {
457
471
  // sculpting is shown as that -- the purpose on the header, the
458
472
  // sculpt where the boilerplate used to stand. The boilerplate stays
459
473
  // for a hand-authored host that declared neither.
460
- const purpose = room.host?.purpose;
474
+ // The purpose is still the searched-for fact in here (hostPurposeFor):
475
+ // standing inside a host is not the same as having read its index.
476
+ // The SCULPT is not gated -- it is the architecture around you, and
477
+ // you are looking at it.
478
+ const purpose = hostPurposeFor(room, actor);
461
479
  const sculpt = room.host?.sculpt;
462
480
  return [
463
481
  `{light-blue-fg}INSIDE :: ${hostLabel(room, { capital: true }).toUpperCase()} [HR ${room.hostRating}]${purpose ? ` -- ${purpose}` : ''}{/light-blue-fg}`,
@@ -559,8 +577,11 @@ export function hostInteriorPanel(scene, actor, host, rows, cols) {
559
577
  const cut = (raw) => `{light-blue-fg}${[...raw].slice(0, Math.max(4, cols)).join('')}{/light-blue-fg}`;
560
578
  const lines = [];
561
579
  lines.push(cut(`INSIDE ${host.name}`));
562
- if (host.purpose)
563
- lines.push(cut(host.purpose));
580
+ // Same gate as every other renderer (hostPurposeFor): the dock is a
581
+ // view, not a back door.
582
+ const purpose = hostPurposeFor(host.over, actor);
583
+ if (purpose)
584
+ lines.push(cut(purpose));
564
585
  const marks = host.marksBy.get(actor.name) ?? 0;
565
586
  lines.push(cut(host.sanctioned
566
587
  ? 'haven -- welcome here'
@@ -574,7 +595,13 @@ export function hostInteriorPanel(scene, actor, host, rows, cols) {
574
595
  continue;
575
596
  lines.push(cut(`◆ ${a.name}`));
576
597
  }
577
- const files = host.files().length;
598
+ // THE COUNT IS PART OF THE ARCHIVE (np6YBSPsdfAY7NcuZ). The icon list
599
+ // above gates the file NAMES on a Matrix Search; this line quietly
600
+ // published how many there were regardless, which is most of what the
601
+ // roll was protecting -- "three files sealed" tells a runner whether
602
+ // the trip is worth it. Same gate, same set.
603
+ const indexed = host.cracked || host.searchedBy.has(actor.name);
604
+ const files = indexed ? host.files().length : 0;
578
605
  if (files > 0)
579
606
  lines.push(cut(`◇ ${files} file${files === 1 ? '' : 's'} ${host.cracked ? 'open' : 'sealed'}`));
580
607
  return lines.slice(0, Math.max(1, rows));
@@ -47,6 +47,18 @@ async function tell(enc, scene, turn) {
47
47
  */
48
48
  export async function hostTurnStart(scene, host, enc) {
49
49
  const out = empty();
50
+ // SUPPRESSION (p.257, sprite-power-actions.ts): a sprite arguing with
51
+ // the host's launch paths delays what it brings up by (Level / 2)
52
+ // Combat Turns, and "delayed IC can't act or be targeted" -- which is
53
+ // exactly "the launch step does not happen this turn". Counted down
54
+ // here so the delay is spent whether or not the host wanted to launch.
55
+ if (host.icLaunchDelay > 0) {
56
+ host.icLaunchDelay -= 1;
57
+ out.lines.push(`{light-blue-fg}Something in ${host.name}'s architecture is arguing with itself -- whatever it meant to bring up does not arrive.{/light-blue-fg}`);
58
+ seatRunningIce(enc, host);
59
+ await tell(enc, scene, out);
60
+ return;
61
+ }
50
62
  hostLaunchStep(host.over, scene, out);
51
63
  seatRunningIce(enc, host);
52
64
  await tell(enc, scene, out);
@@ -18,6 +18,16 @@ export function clearMarksPlacedBy(scene, placerName) {
18
18
  for (const room of Object.values(scene.getRooms())) {
19
19
  if (room.hostMarksBy.delete(placerName))
20
20
  cleared++;
21
+ // A DEVICE'S MARKS ARE MARKS (f79LLqTerep4nKWcA sweep). Device.marksBy
22
+ // is the third ledger -- hack.ts writes it for every slaved and loose
23
+ // icon -- and no wipe had ever touched it, so the maglock you keyed
24
+ // last run still answered to a persona canon says was deleted on
25
+ // reboot (p.236). It also meant `disable` could drive a device on
26
+ // marks the sheet no longer showed you holding.
27
+ for (const device of room.allDevices()) {
28
+ if (device.marksBy.delete(placerName))
29
+ cleared++;
30
+ }
21
31
  }
22
32
  for (const a of scene.allActors) {
23
33
  if (a.panMarksBy.delete(placerName))
@@ -71,6 +81,13 @@ export function describeMarks(scene, placerName) {
71
81
  const n = room.hostMarksBy.get(placerName) ?? 0;
72
82
  if (n > 0)
73
83
  lines.push(`● ${hostLabel(room, { capital: true })} -- ${n} mark${n === 1 ? '' : 's'}${n >= MAX_MARKS ? ' (it thinks you belong)' : ''}`);
84
+ // Devices too -- the ledger `disable` reads to drive a maglock was
85
+ // invisible on the listing that is supposed to show what you hold.
86
+ for (const device of room.allDevices()) {
87
+ const d = device.marksBy.get(placerName) ?? 0;
88
+ if (d > 0)
89
+ lines.push(`▲ ${device.name} -- ${d} mark${d === 1 ? '' : 's'}`);
90
+ }
74
91
  }
75
92
  for (const a of scene.allActors) {
76
93
  const n = a.panMarksBy.get(placerName) ?? 0;
@@ -92,6 +109,11 @@ export function marksPlaced(scene, placerName) {
92
109
  const n = room.hostMarksBy.get(placerName) ?? 0;
93
110
  if (n > 0)
94
111
  out.push({ target: hostLabel(room, { capital: true }), count: n });
112
+ for (const device of room.allDevices()) {
113
+ const d = device.marksBy.get(placerName) ?? 0;
114
+ if (d > 0)
115
+ out.push({ target: device.name, count: d });
116
+ }
95
117
  }
96
118
  for (const a of scene.allActors) {
97
119
  const n = a.panMarksBy.get(placerName) ?? 0;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WHO IS WATCHING THE DICE (wjFBY3zgyWzJP9adw, Deditri: "the sprite put a
3
+ * mark on the target but there was no output about the rolls in the
4
+ * logs").
5
+ *
6
+ * The sprite's dice WERE rolled. `order <sprite> mark <lock>` runs the
7
+ * real verb with the sprite as actor -- commands/order.ts hands the
8
+ * string to NPC.tryRunCommand, commands/mark.ts re-dispatches into
9
+ * HackCommand, and hack.ts rolls Hack on the Fly against the device
10
+ * exactly as it would for a decker. The roll was then thrown away at the
11
+ * REPORTING layer, by a guard that has been copied into ~30 commands:
12
+ *
13
+ * if (this.scene.isHumanControlled(actor)) this.logger.meta(...)
14
+ *
15
+ * That asks "is the ROLLER a human". The question the Mechanics panel
16
+ * actually wants is "is a HUMAN WATCHING" -- and the codebase already
17
+ * answers it correctly two files over, in CombatExchange.playerWitnesses
18
+ * (utilities/combat-exchange.ts), which is precisely why a sprite's
19
+ * COMBAT rolls have always shown up while the same sprite's ordered
20
+ * mark went silent. One question, asked two ways, in one engine.
21
+ *
22
+ * WHY OWNERSHIP AND NOT PROXIMITY. playerWitnesses is a bystander test:
23
+ * same place, perceivable plane. That is right for a fight, which is an
24
+ * event in a room. It is wrong for an ORDER, which is a thing the player
25
+ * deliberately did: a rigger whose drone is sweeping the floor above,
26
+ * or a technomancer whose sprite is working a host their body is nowhere
27
+ * near, typed that command and is owed its dice. So a companion's rolls
28
+ * are shown to its master wherever it is standing. Anything that is
29
+ * NOT a companion -- IC, a guard, a passer-by -- keeps the old silence
30
+ * and reports through the channels it already had (utilities/ic.ts
31
+ * accumulates into IICTurn.meta; a fight goes through announce).
32
+ *
33
+ * THE FORK MUST STAY EXCLUSIVE. Several commands route the roll to
34
+ * EITHER the Mechanics panel (player) OR their own return string, which
35
+ * for an NPC is its AI history:
36
+ *
37
+ * const isPlayer = scene.isHumanControlled(actor);
38
+ * if (isPlayer) this.logger.meta(`Perception: ${formatRoll(roll)}`);
39
+ * const rollPrefix = isPlayer ? '' : `Perception: ${formatRoll(roll)}\n`;
40
+ *
41
+ * Both halves must read the SAME predicate or an ordered companion
42
+ * prints its dice twice. Callers swapping to showsMechanics must swap
43
+ * both.
44
+ */
45
+ /** The companion ledger entry for this actor, if it is somebody's. */
46
+ function companionEntryFor(scene, actor) {
47
+ return scene.ownerGame?.companions?.find(c => c.npc === actor);
48
+ }
49
+ /**
50
+ * Should this actor's dice anatomy go to the Mechanics panel?
51
+ *
52
+ * True for a human-controlled actor (the old rule, unchanged), and for a
53
+ * companion a human is running -- a sprite, spirit, agent or drone whose
54
+ * master typed the order that got here.
55
+ */
56
+ export function showsMechanics(scene, actor) {
57
+ if (scene.isHumanControlled?.(actor) === true)
58
+ return true;
59
+ return companionEntryFor(scene, actor) !== undefined;
60
+ }
61
+ /**
62
+ * The name to hang on a Mechanics line when the roller is not the player
63
+ * -- "Crack Sprite -- Hack on the Fly (sleaze) on Hatch Maglock: ...".
64
+ * Empty for the player's own rolls, which stay exactly as they read
65
+ * today: a panel that started prefixing the player's own name on every
66
+ * line would be noisier for the common case to serve the rare one.
67
+ */
68
+ export function mechanicsActorPrefix(scene, actor) {
69
+ return scene.isHumanControlled?.(actor) === true ? '' : `${actor.name} -- `;
70
+ }
71
+ //# sourceMappingURL=mechanics-audience.js.map
@@ -2,6 +2,7 @@ import { Category } from '../types/shared/item-enum.js';
2
2
  import { inMeleeReach, isInCover, coverAvailableFor, actorDistanceMeters } from './spots.js';
3
3
  import { Logger } from './logger.js';
4
4
  import { runIcActionPhase } from './ic-brain.js';
5
+ import { moraleBandFor, moraleBreakLine, moraleBreaks } from '../npc-kits.js';
5
6
  /**
6
7
  * AN NPC'S ACTION PHASE, PLAYED BY RULE (SR5 p.163-167).
7
8
  *
@@ -54,6 +55,27 @@ export async function runNpcActionPhase(enc, npc) {
54
55
  tell(`${npc.name} looks for a target and finds none.`);
55
56
  return;
56
57
  }
58
+ // MORALE (SR5 p.379-380, npc-kits.ts moraleBreaks): a team of grunts
59
+ // breaks at the book's casualty thresholds -- untrained when one
60
+ // drops, semi-trained past a quarter, trained past half, elite never.
61
+ // THE VERB IS SURRENDER where the book says flee / retreat / withdraw:
62
+ // this engine's one primitive for "stops fighting without dying" is
63
+ // `surrender`, and it carries the consequence the book intends -- the
64
+ // fight ends cleanly, no parting shot, and the pair drops out of
65
+ // stillHostile(). A grunt sent through `go` instead would stand in the
66
+ // next room still hostile and still on the contact ladder, which is
67
+ // the opposite of having broken. The THRESHOLD is canon; only the word
68
+ // is the engine's. Without this, a kitted ganger who fights to the
69
+ // death is a harder, longer, less canon fight than a naked one.
70
+ const team = enc.teamOf(npc);
71
+ if (team && moraleBreaks(team.effectivePr, team.casualties, team.total)) {
72
+ const r = await npc.actInCombat('surrender');
73
+ logger.write(`Brain: ${npc.name} morale breaks (${team.kit}: ${team.casualties}/${team.total} down, PR ${team.effectivePr}) -> ${r}`);
74
+ if (npc.surrendered) {
75
+ tell(moraleBreakLine(npc.name, moraleBandFor(team.effectivePr)));
76
+ return;
77
+ }
78
+ }
57
79
  const target = pickTarget(npc, enemies);
58
80
  npc.combatOpponent = target;
59
81
  npc.lastExchangeAt = Date.now();
@@ -109,6 +131,7 @@ export async function runNpcActionPhase(enc, npc) {
109
131
  }
110
132
  const b = enc.budgetOf(npc);
111
133
  if (b?.complexAvailable) {
134
+ spendGroupEdge(enc, npc, tell);
112
135
  const r = await npc.actInCombat(`attack ${target.name}`);
113
136
  logger.write(`Brain: ${npc.name} attack ${target.name} -> ${r.split('\n')[0]}`);
114
137
  if (!enc.budgetOf(npc)?.attackTaken)
@@ -126,6 +149,7 @@ export async function runNpcActionPhase(enc, npc) {
126
149
  continue;
127
150
  }
128
151
  if (!budget.attackTaken && budget.simple >= 1) {
152
+ spendGroupEdge(enc, npc, tell);
129
153
  const r = await npc.actInCombat(`attack ${target.name}`);
130
154
  logger.write(`Brain: ${npc.name} attack ${target.name} -> ${r.split('\n')[0]}`);
131
155
  if (!enc.budgetOf(npc)?.attackTaken) {
@@ -139,6 +163,18 @@ export async function runNpcActionPhase(enc, npc) {
139
163
  return;
140
164
  }
141
165
  }
166
+ /**
167
+ * GROUP EDGE (SR5 p.380): the gamemaster spends the team's pool on a
168
+ * grunt about to strike. The engine is the gamemaster; the policy is
169
+ * CombatEncounter.spendGroupEdge's. One line to the room when it lands,
170
+ * so the runner knows why that shot rolled hot.
171
+ */
172
+ function spendGroupEdge(enc, npc, tell) {
173
+ if (npc.kit === undefined)
174
+ return;
175
+ if (enc.spendGroupEdge(npc))
176
+ tell(`${npc.name} digs deep.`);
177
+ }
142
178
  /** The opponent already engaged, else the nearest of the rest. */
143
179
  function pickTarget(npc, enemies) {
144
180
  const engaged = npc.combatOpponent;
@@ -184,6 +184,8 @@ export function serializeItem(item) {
184
184
  out.revealedTo = [...item.revealedTo];
185
185
  if (item.isCyberdeck() && item.deckOrder.join() !== '0,1,2,3')
186
186
  out.deckOrder = item.deckOrder;
187
+ if (item.ruined)
188
+ out.ruined = true;
187
189
  return out;
188
190
  }
189
191
  /** Mirrors ItemFactory.createItemFromJson (synchronously), then lays
@@ -223,6 +225,8 @@ export function restoreItem(json) {
223
225
  item.jammed = true;
224
226
  if (json.bricked)
225
227
  item.bricked = true;
228
+ if (json.ruined)
229
+ item.ruined = true;
226
230
  if (json.deckDamage)
227
231
  item.takeDeckDamage(json.deckDamage);
228
232
  if (json.droneDamage)
@@ -601,6 +605,9 @@ function serializeRoomOverlay(room, player) {
601
605
  inventory: b.inventory.getAllItems().map(serializeItem),
602
606
  })),
603
607
  searchedBy: [...room.searchedBy],
608
+ // A SANCTIONED HOST KEEPS NO INDEX EITHER, for the same reason it
609
+ // forgets a crack: a haven is not a target you cased.
610
+ hostSearchedBy: room.hostSanctioned ? [] : [...(room.host?.searchedBy ?? [])],
604
611
  visitedBy: [...room.visitedBy],
605
612
  // A SANCTIONED HOST NEVER REMEMBERS A CRACK (Room.hostSanctioned).
606
613
  // The overlay rule is right for run content -- you broke that
@@ -771,6 +778,10 @@ export function applyHubOverlay(scene, saved, player, homeRoom) {
771
778
  return { name: b.name, cell: { ...b.cell }, inventory: held };
772
779
  });
773
780
  room.searchedBy = new Set(overlay.searchedBy);
781
+ // Only onto a room that HAS a host -- a seed that dropped its node
782
+ // between saves must not grow one back through the overlay.
783
+ if (room.host)
784
+ room.host.searchedBy = new Set(overlay.hostSearchedBy ?? []);
774
785
  room.visitedBy = new Set(overlay.visitedBy);
775
786
  // Both ends, so an OLD save written before hostSanctioned existed
776
787
  // cannot restore a cracked haven either.
@@ -14,7 +14,13 @@
14
14
  */
15
15
  export const PROGRAMS = [
16
16
  // ---- Common programs (p.245; pricing canon-approximate) ----
17
- { key: 'browse', name: 'Browse', kind: 'common', cost: 80, availability: 2, effect: '+2 dice on Matrix Search while jacked in.' },
17
+ // BROWSE BUYS TIME, NOT DICE (Splintered State p.54: it "cuts the base
18
+ // time in half"). The catalogue advertised "+2 dice on Matrix Search"
19
+ // and no code ever added them -- utilities/perception.ts has always
20
+ // halved the clock, which is the half that is right. A program whose
21
+ // shop blurb promises a bonus it does not grant is a lie the player
22
+ // pays 80 nuyen for.
23
+ { key: 'browse', name: 'Browse', kind: 'common', cost: 80, availability: 2, effect: 'Halves the base time of a Matrix Search (p.241).' },
18
24
  { key: 'toolbox', name: 'Toolbox', kind: 'common', cost: 80, availability: 2, effect: '+1 Data Processing.' },
19
25
  { key: 'encryption', name: 'Encryption', kind: 'common', cost: 80, availability: 2, effect: '+1 Firewall.' },
20
26
  { key: 'signal-scrub', name: 'Signal Scrub', kind: 'common', cost: 80, availability: 2, effect: 'Cleans your noise floor: +1 die defending your PAN.' },