@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
@@ -8,6 +8,7 @@ import { hostLabel } from '../utilities/grid-names.js';
8
8
  import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
9
9
  import { placeName } from '../utilities/log-style.js';
10
10
  import { billAction, encounterOf } from '../utilities/action-cost.js';
11
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
11
12
  /**
12
13
  * Barrier rendering (2026-08-24 playtest: "I don't see THE THING I'm
13
14
  * supposed to act on -- just a description... and I still see the
@@ -166,9 +167,8 @@ export class SearchCommand extends Command {
166
167
  const { pool, limit } = matrixSearchTest(actor);
167
168
  const roll = rollPool(pool, limit);
168
169
  const seconds = matrixSearchSeconds(row.seconds, roll.hits, row.threshold, browse);
169
- const isPlayer = this.scene.isHumanControlled(actor);
170
- if (isPlayer) {
171
- this.logger.meta(`Matrix Search (Computer + Intuition [Data Processing], threshold ${row.threshold}${browse ? ', Browse' : ''}): ${formatRoll(roll)} -- ${seconds}s`);
170
+ if (showsMechanics(this.scene, actor)) {
171
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Matrix Search (Computer + Intuition [Data Processing], threshold ${row.threshold}${browse ? ', Browse' : ''}): ${formatRoll(roll)} -- ${seconds}s`);
172
172
  }
173
173
  // THE MINUTES ARE GOD'S (p.231-232): Overwatch bills time on the
174
174
  // grid, and a search is time. Winding the last tick back by the
@@ -176,7 +176,12 @@ export class SearchCommand extends Command {
176
176
  if (actor.overwatchLastTick > 0)
177
177
  actor.overwatchLastTick -= seconds * 1000;
178
178
  actor.performAction('runs a Matrix Search', host ? `inside ${hostLabel(where)}` : `across ${where.name}'s grid`, { quiet: this.scene.isHumanControlled(actor) });
179
- const files = where.offlineServer ? [] : where.inventory.getAllItems().filter(i => i.plane === 'matrix');
179
+ // CAMOUFLAGE (p.256, sprite-power-actions.ts): a file a sprite folded
180
+ // into another file is "invisible to Matrix searches" -- only a Matrix
181
+ // Perception specifically hunting it turns it up. This is the search
182
+ // it is invisible to.
183
+ const files = where.offlineServer ? [] : where.inventory.getAllItems()
184
+ .filter(i => i.plane === 'matrix' && !i.concealedBy);
180
185
  const clock = `(${seconds}s of Matrix time${browse ? ', Browse cutting it' : ''})`;
181
186
  if (roll.hits < row.threshold) {
182
187
  return host
@@ -210,8 +215,24 @@ export class SearchCommand extends Command {
210
215
  // minute inside a host), not in Action Phases -- there is no cost to
211
216
  // bill inside a Combat Turn, so it is refused there rather than
212
217
  // priced by invention.
213
- if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
214
- return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice, "hide" from it (p.240), or get out first.`;
218
+ //
219
+ // A FINISHED FIGHT IS NOT A FIGHT. This asked `encounterOf(...)` and
220
+ // nothing else, while every other seam in action-cost.ts -- billAction,
221
+ // requirePhase, inLiveCombat -- pairs it with `!enc.ended`. An
222
+ // encounter object outlives the fight it ran, so a persona that had
223
+ // crashed the ice and watched the walls come down kept being told to
224
+ // crash the ice: the refusal named an exit the player had already
225
+ // taken. That is the reported "it says I can't do it with an IC
226
+ // there" (user, 2026-09-15), and the reason the answer to "is that
227
+ // correct?" is yes-but-not-here.
228
+ const enc = encounterOf(this.scene, this.actor);
229
+ if (this.actor.plane === 'matrix' && enc && !enc.ended) {
230
+ // Name the action that DOES fit in three seconds. The old line
231
+ // listed only ways to leave, which reads as "not now" when canon's
232
+ // answer is "not this verb" -- Matrix Perception is the Complex
233
+ // Action for looking at things mid-fight, and `look` is it
234
+ // (commands/look.ts bills exactly that).
235
+ return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. "look" is Matrix Perception (p.241), the Complex Action you CAN take right now. Otherwise crash the ice, "hide" from it (p.240), or get out first.`;
215
236
  }
216
237
  if (this.actor.plane === 'matrix')
217
238
  return this.matrixSearch();
@@ -238,10 +259,15 @@ export class SearchCommand extends Command {
238
259
  // Roll anatomy to the Mechanics ticker for the player; an NPC's
239
260
  // return string is its AI history, so IT keeps the roll inline
240
261
  // (readability pass).
241
- const isPlayer = this.scene.isHumanControlled(this.actor);
242
- if (isPlayer)
243
- this.logger.meta(`Perception: ${formatRoll(roll)}`);
244
- const rollPrefix = isPlayer ? '' : `Perception: ${formatRoll(roll)}\n`;
262
+ // ONE PREDICATE FOR BOTH HALVES. The roll goes to the Mechanics panel
263
+ // OR into the return string (an NPC's AI history), never both -- so
264
+ // when the gate widened to companions (showsMechanics), the inline
265
+ // copy had to narrow by exactly the same amount or an ordered drone
266
+ // would print its Perception twice.
267
+ const toPanel = showsMechanics(this.scene, this.actor);
268
+ if (toPanel)
269
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Perception: ${formatRoll(roll)}`);
270
+ const rollPrefix = toPanel ? '' : `Perception: ${formatRoll(roll)}\n`;
245
271
  if (roll.hits === 0) {
246
272
  return `${rollPrefix}You sweep the room and come up empty... you think.${describeContainers(room, this.actor)}${describeBarriers(room, this.actor)}`;
247
273
  }
@@ -0,0 +1,85 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { NPC } from '../models/npc.js';
4
+ import { SPRITE_POWERS, SPRITE_POWER_TASK_RULE, powersForSprite } from '../sprite-powers.js';
5
+ import { runSpritePower } from '../utilities/sprite-power-actions.js';
6
+ /**
7
+ * A SPRITE POWER AS A TYPED MOVE (user 2026-09-15).
8
+ *
9
+ * The verb surface is `order <sprite> <power> [target]` -- order.ts
10
+ * already parses "order <name> <command>" and hands the rest to
11
+ * NPC.tryRunCommand, so each power name registers as an ordinary verb
12
+ * that only a sprite can run. That keeps the task accounting, the
13
+ * companion feed and the Mechanics routing that "order" already does,
14
+ * instead of building a second way to tell a sprite to do something.
15
+ *
16
+ * ONE TASK PER USE (p.254, p.256 -- SPRITE_POWER_TASK_RULE). order.ts
17
+ * bills it on the success path, so a refusal here costs nothing. That is
18
+ * why every guard below returns before the resolver runs.
19
+ */
20
+ export class SpritePowerCommand extends Command {
21
+ static verb = 'cookie';
22
+ static description = 'A sprite power (SR5 p.256-257). Order it: "order <sprite> <power> <target>". "sprites" lists which sprite has which.';
23
+ static npcPolicy = 'open';
24
+ /** Which power this registration is -- set per alias at registration. */
25
+ powerKey() {
26
+ return this.constructor.verb;
27
+ }
28
+ async execute(args = []) {
29
+ const actor = this.actor;
30
+ const key = this.powerKey();
31
+ // A PERSON CANNOT DO THESE. Sprite powers are the thing a sprite has
32
+ // that its technomancer does not (Sz7EmXnyRYK4RYqxt's whole point),
33
+ // so a player typing "cookie" is told where the verb lives rather
34
+ // than being quietly refused by a type check.
35
+ if (!(actor instanceof NPC)) {
36
+ const power = SPRITE_POWERS.find(p => p.key === key);
37
+ return `${power?.name ?? key} is a SPRITE power (${power?.page ?? 'p.256-257'}) -- not something a persona does. ${hint(`Order it: "order <sprite> ${key} <target>". "sprites" shows which sprite has which.`)}`;
38
+ }
39
+ const game = this.scene.ownerGame;
40
+ const entry = game?.companions?.find(c => c.npc === actor);
41
+ if (!entry || entry.kind !== 'sprite') {
42
+ return `Only a sprite runs a sprite power (p.256).`;
43
+ }
44
+ const spriteKey = entry.spriteType ?? '';
45
+ const master = this.scene.getPlayers?.()[0];
46
+ if (!master)
47
+ return `No one is holding this sprite's thread.`;
48
+ // The p.259 database decides who has what; the resolver re-checks,
49
+ // but naming the sprite's own list here is the useful refusal.
50
+ if (!powersForSprite(spriteKey).some(p => p.key === key)) {
51
+ const mine = powersForSprite(spriteKey).map(p => p.name).join(', ');
52
+ return `${actor.name} is a ${spriteKey} sprite -- no ${SPRITE_POWERS.find(p => p.key === key)?.name ?? key} (p.259). It can do: ${mine || 'nothing listed'}.`;
53
+ }
54
+ // DIAGNOSTICS TAKES THE SPRITE'S WHOLE ATTENTION (p.257): any other
55
+ // order drops the bonus it was holding. Cleared before the new power
56
+ // runs, so the sprite cannot be assisting and attacking at once.
57
+ if (master.spriteAssist?.sprite === actor.name && key !== 'diagnostics') {
58
+ master.spriteAssist = undefined;
59
+ }
60
+ const result = runSpritePower(key, {
61
+ scene: this.scene,
62
+ sprite: actor,
63
+ level: entry.force,
64
+ spriteKey,
65
+ master,
66
+ }, args.join(' '));
67
+ for (const line of result.meta)
68
+ this.logger.meta(line);
69
+ if (!result.spent)
70
+ return result.text;
71
+ this.scene.updateStatus?.();
72
+ return `${result.text}${hint(`\n${SPRITE_POWER_TASK_RULE}`)}`;
73
+ }
74
+ }
75
+ /** One registered verb per power, so "order <sprite> gremlins <lock>" parses
76
+ * through the ordinary command table rather than a second dispatcher. */
77
+ export const SPRITE_POWER_COMMANDS = Object.fromEntries(SPRITE_POWERS.map(power => {
78
+ const cls = class extends SpritePowerCommand {
79
+ static verb = power.key;
80
+ static description = `${power.name} (${power.page}): ${power.effect}`;
81
+ powerKey() { return power.key; }
82
+ };
83
+ return [power.key, cls];
84
+ }));
85
+ //# sourceMappingURL=sprite-power.js.map
@@ -82,7 +82,14 @@ export class SpritesCommand extends Command {
82
82
  continue;
83
83
  }
84
84
  for (const p of powers) {
85
- lines.push(` ${p.name}${hint(` (${p.page})`)}`);
85
+ // WHICH POWERS ARE MOVES (ISpritePower.runnable). Eight of the
86
+ // nine resolve; Stability is still catalogue, and the panel says
87
+ // so on the power itself rather than letting a player find out
88
+ // by typing it -- a wrong ability is worse than a missing one.
89
+ const how = p.runnable
90
+ ? hint(` -- "order <sprite> ${p.key} <target>"`)
91
+ : ` {yellow-fg}(reference only -- not a move yet){/yellow-fg}`;
92
+ lines.push(` ${p.name}${hint(` (${p.page})`)}${how}`);
86
93
  lines.push(` ${p.effect}`);
87
94
  // The Test line is the sharpest thing on the panel for a player
88
95
  // deciding between two types -- it says what the power rolls
@@ -95,8 +102,10 @@ export class SpritesCommand extends Command {
95
102
  }
96
103
  const footer = [
97
104
  SPRITE_POWER_TASK_RULE,
98
- // Said plainly, not buried: see the class comment.
99
- `These powers are CANON REFERENCE -- none is a move you can type yet. What a compiled sprite actually does today is fight ice at your side and take "order".`,
105
+ // Said plainly, not buried: see the class comment. Eight of the
106
+ // nine became real moves on 2026-09-15; Stability did not, and the
107
+ // footer is not allowed to round that up.
108
+ `Order a power like any other command: "order <sprite> <power> <target>". Stability alone is still reference -- it is listed because canon has it, not because it does anything yet.`,
100
109
  detailed ? `"sprites" lists all five.` : `"sprites <type>" for what each power actually does. "compile <type> <level>" calls one up.`,
101
110
  ].join(' ');
102
111
  const tail = hint(footer);
@@ -5,6 +5,7 @@ import { accrueOverwatch } from '../utilities/overwatch.js';
5
5
  import { MAX_MARKS } from '../utilities/marks.js';
6
6
  import { isWatched, cameraMasterHost, directConnectionBlocker } from '../utilities/surveillance.js';
7
7
  import { hint } from '../utilities/hints.js';
8
+ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
8
9
  /**
9
10
  * TAP -- THE DIRECT CONNECTION (backlog items 9 and 10, Niko, session
10
11
  * 2026-08-27T02-07-12-404Z: "check the rules on this -- shouldn't I be
@@ -103,8 +104,8 @@ export class TapCommand extends Command {
103
104
  const pool = Math.max(1, actor.logic + actor.skillRating('hacking') + actor.bonus('hacking') + silence + actor.woundModifier - actor.sustainingPenalty);
104
105
  actor.performAction('cables into the cameras', `${room.name} -- a hand on the housing, a filament out of their deck`);
105
106
  const roll = rollPool(pool, limit);
106
- if (this.scene.isHumanControlled(actor)) {
107
- this.logger.meta(`Direct connection, camera (${mode === 'attack' ? 'brute force' : 'sleaze'}): ${formatRoll(roll)} (${threshold}+ -- its OWN ratings, not the host's)`);
107
+ if (showsMechanics(this.scene, actor)) {
108
+ this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Direct connection, camera (${mode === 'attack' ? 'brute force' : 'sleaze'}): ${formatRoll(roll)} (${threshold}+ -- its OWN ratings, not the host's)`);
108
109
  }
109
110
  this.logger.write(`${actor.name} direct-connected to ${room.name}'s cameras (${mode}): ${roll.hits} vs ${threshold}, master=${host.name}.`);
110
111
  // GOD counts it either way (p.231-232) -- the same flat 2 a host
@@ -577,5 +577,169 @@
577
577
  // - BENCH: repro schema 2.6.0 adds npcs[].hostile. A fixture could not
578
578
  // express a hostile PERSON -- only `ice` -- so no meat-side
579
579
  // hostility report could be benched at all.
580
- export const ENGINE_VERSION = '1.58.0';
580
+ // 1.59.0 (2026-09-15): A HOST'S BUSINESS IS SOMETHING YOU SEARCH FOR.
581
+ // - THE PURPOSE PHRASE IS ARCHIVE CONTENT (np6YBSPsdfAY7NcuZ). The
582
+ // generator is told to fill Host.purpose with "what is worth
583
+ // protecting inside it", so in practice it enumerates the vault --
584
+ // "patient intake logs, med supply orders, and the doc's private
585
+ // black book of who paid what for what" -- and four renderers
586
+ // printed that on a SEALED host's outside icon for free, while the
587
+ // file names beside it were correctly gated. Two tiers now, both
588
+ // rolls the engine already made: Room.searchedBy (the threshold-1
589
+ // public row, or a meat search of the room) reveals the business;
590
+ // Host.searchedBy (threshold 3, from inside) still names the files.
591
+ // A cracked host spills both. One gate, grid-names.hostPurposeFor,
592
+ // read by hostLine, gridIcons, gridSculpt and hostInteriorPanel.
593
+ // The dock's "N files sealed" counter was ungated too and is now.
594
+ // - SAVE SHAPE: ISavedRoomOverlay.hostSearchedBy (optional) persists
595
+ // Host.searchedBy, which was session-only. Survivable while it hid
596
+ // a file list; not once it gates the purpose, because the search
597
+ // that opens it spends Matrix minutes and those minutes wind the
598
+ // Overwatch clock. A reload was charging the player twice.
599
+ // - A FINISHED FIGHT IS NOT A FIGHT. Matrix Search's Combat Turn
600
+ // refusal asked `encounterOf(...)` alone, while billAction,
601
+ // requirePhase and inLiveCombat all pair it with `!enc.ended` -- so
602
+ // a persona that had already crashed the ice kept being told to
603
+ // crash the ice. The refusal is correct in a LIVE fight (base time
604
+ // one minute, p.241, against a three-second Combat Turn) and now
605
+ // also names the action that does fit: Matrix Perception, which is
606
+ // `look`.
607
+ // - Browse's catalogue blurb promised "+2 dice on Matrix Search" and
608
+ // no code ever granted them; it halves the base TIME (Splintered
609
+ // State p.54), which is what perception.ts has always done.
610
+ // 1.60.0 (2026-09-15): THE MECHANICS PANEL ASKS WHO IS WATCHING.
611
+ // - AN ORDERED COMPANION'S DICE WERE ROLLED AND THROWN AWAY
612
+ // (wjFBY3zgyWzJP9adw). `order <sprite> mark <lock>` runs the real
613
+ // verb with the sprite as actor and rolls Hack on the Fly exactly as
614
+ // a decker would -- then ~30 commands dropped the result on the
615
+ // floor, because the guard around every logger.meta asked
616
+ // `isHumanControlled(actor)`: is the ROLLER a human. The panel wants
617
+ // "is a human WATCHING", which combat-exchange has always asked
618
+ // (playerWitnesses) -- which is why a sprite's COMBAT rolls showed
619
+ // and its ordered mark did not. New utilities/mechanics-audience.ts:
620
+ // showsMechanics is true for a human OR a companion a human is
621
+ // running, and the line names the roller when it is not the player.
622
+ // Swept across hack, disable, edit-file, hide, jack, overwatch, tap,
623
+ // look and search -- sprites, spirits, agents and drones alike.
624
+ // Where a command forked meta-vs-inline on the old predicate, BOTH
625
+ // halves moved, so nothing prints twice.
626
+ // - A SPRITE'S TASKS ARE A BARGAIN TOO. order.ts billed services for
627
+ // spirits only, so an ordered sprite worked free and its ledger line
628
+ // never printed -- while combat-exchange has always charged both per
629
+ // fight. Canon is explicit (p.254, p.256): a task per order, and the
630
+ // refusal when the thread is spent now speaks sprite, not spirit.
631
+ // - The un-jumped drone recon's Piloting test wrote its dice into the
632
+ // PROSE line (drone-prose.droneReconLaunchLine took a rollText) --
633
+ // the one player roll in the engine you had to read out of a
634
+ // sentence. It goes to the panel; the prose is prose again.
635
+ // 1.61.0 (2026-09-15): ERASE MARK (f79LLqTerep4nKWcA). New verb
636
+ // "unmark", also reached as "erase mark[s] on <icon>" -- a Complex
637
+ // Action, Computer + Logic [Attack] v. Willpower + Firewall (p.239),
638
+ // an ATTACK action so GOD bills the defender's hits. Nothing removed a
639
+ // mark before this: the only paths were the three canon wipes in
640
+ // utilities/marks.ts (reboot, jack-out, convergence), and `erase` was
641
+ // Edit File's delete on a FILE.
642
+ // - THE THREE-MARK GATE is the book's: you need three marks on the
643
+ // icon you are scrubbing, not on whoever placed the mark. Two marks
644
+ // in one action at -4, three at -10, all from the same icon.
645
+ // - THE OWNER EXEMPTION IS AN ENGINE RULING, labelled as one at the
646
+ // command. Read strictly, p.239 would make a decker mark themselves
647
+ // three times before clearing an IC's key off their own persona --
648
+ // marks nothing in the engine or the book lets you place on
649
+ // yourself. The gate stops you reaching into someone ELSE's icon;
650
+ // on your own it is waived and the roll still stands. When you hold
651
+ // too few marks to reach another icon, the refusal names reboot and
652
+ // jack out, which do it for free.
653
+ // - "unmark me" sweeps BOTH ledgers -- a host's key lives in
654
+ // Host.marksOn, not your PAN ledger -- and clearing a host's last
655
+ // key un-spots you (Host.spotted): an icon it no longer recognises
656
+ // is one it has to find again.
657
+ // - TWO LEDGER BUGS ALONGSIDE. Device.marksBy is the third mark
658
+ // ledger and no wipe had ever touched it, so a maglock you keyed
659
+ // last run still answered to a persona canon deleted on reboot
660
+ // (p.236) -- and `disable` could drive it on marks the sheet no
661
+ // longer showed. It is cleared by clearMarksPlacedBy now, and
662
+ // listed by describeMarks/marksPlaced, which had both skipped it.
663
+ // 1.62.0 (2026-09-15): HIRING THE SCRUB. A runner who cannot reach the
664
+ // Matrix can now pay somebody who can. "ask <contact> about cleanup"
665
+ // quotes a price (no dice, no legwork budget spent); "hire cleanup
666
+ // [<site>]" haggles it once on Negotiation, debits the nuyen and
667
+ // stamps the loose end. The decker's own test rolls at the NEXT
668
+ // homecoming -- you paid for a result you do not get to watch.
669
+ // Clean, leverage (done, but they kept a copy) or botched (footage
670
+ // stays, grace clock keeps running, no refund).
671
+ // - WHY: a run leaves an ILooseEnd and the hub nagged EVERY runner to
672
+ // "jack in, then hop" and erase it -- a route that needs a cyberdeck
673
+ // and a direct neural interface. A street samurai was handed a
674
+ // consequence with no lever and a +1 Public Awareness on a timer.
675
+ // The playable cleanup scene is untouched; this is the other door.
676
+ // - SAVE SHAPE: ILooseEnd.hired (optional, additive -- no
677
+ // SAVE_VERSION bump). It rides the record that was already
678
+ // persisted, so a job already paid for survives a reload, and the
679
+ // fixer's Connection + Loyalty is BANKED at hire time so a loyalty
680
+ // shift in between cannot retroactively change it.
681
+ // - New Player.canReachMatrix(), the question commands/jack.ts answers
682
+ // in three separate refusals, asked once so the hub's advice can be
683
+ // written for the runner reading it.
684
+ // 1.63.0 (2026-09-15): SPRITE POWERS ARE MOVES. sprite-powers.ts has
685
+ // been a canon-complete, individually cited catalogue of all nine
686
+ // since Sz7EmXnyRYK4RYqxt -- and every one was a printed Test line
687
+ // wired to nothing. Eight of them resolve now
688
+ // (utilities/sprite-power-actions.ts), keyed off the same five sprite
689
+ // keys so the panel and the mechanics cannot drift. Ordered like any
690
+ // other command: "order <sprite> <power> <target>", one TASK per use
691
+ // (p.254/p.256), billed on success so a refusal is free.
692
+ // Cookie Hacking + Resonance [Sleaze] v. Intuition+Firewall
693
+ // Diagnostics Hardware + Level [Data Processing], Teamwork
694
+ // Electron Storm Cybercombat + Resonance [Attack], sustained
695
+ // Gremlins Hardware + Level [Attack] v. Device Rating+Firewall
696
+ // Camouflage / Hash / Suppression / Watermark -- state, no roll
697
+ // - NEW CANON DATA: a sprite's four Matrix attributes are PER TYPE
698
+ // (p.259) and only Resonance equals Level (p.254). The engine had no
699
+ // table, which is fine while a power is a printed string and not
700
+ // fine the moment it needs a [Sleaze] number -- a bracket is a LIMIT.
701
+ // spriteMatrix()/spriteMatrixBoxes() carry it. Fault sprites gain
702
+ // Cybercombat and machine sprites Hardware, the skills p.259 already
703
+ // listed on the panel and compile.ts never gave the shell.
704
+ // - CONSUMERS: Camouflage hides a file from Matrix Search
705
+ // (commands/search.ts); Hash blocks Edit File; Suppression delays the
706
+ // host's launch step (utilities/host-combat.ts hostTurnStart);
707
+ // Electron Storm ends on any Matrix damage to the sprite, enforced on
708
+ // Player.takeMatrixDamage -- the one method every Matrix hit goes
709
+ // through. New session state: Item.concealedBy/hashedBy/watermark,
710
+ // Host.icLaunchDelay, Player.cookiedBy/spriteAssist, NPC.sustainedStorm.
711
+ // - STABILITY IS DELIBERATELY NOT WIRED, and both the catalogue and the
712
+ // panel say so per-power (ISpritePower.runnable). It demotes
713
+ // glitches, and glitches are read at sixteen scattered sites in this
714
+ // engine; wiring it at three would be a power that works where
715
+ // somebody happened to look -- the "wrong ability on a sprite is
716
+ // worse than a missing one" case. It wants the glitch contract in one
717
+ // place first, and that is its own piece of work.
718
+ // 1.64.0 (2026-09-15): NPC KITS -- "everyone is basically naked."
719
+ // - NEW CATALOG KIND 'npc-kit' (v21): SR5 p.379-385's Professional
720
+ // Rating tiers 0-6 as rows, each the book's own statblock AND gear
721
+ // (npc-kits.ts reads them; catalog.ts npcKitFor). Before this every
722
+ // NPC was an attribute block: no armor (soak on Body alone), no
723
+ // weapon (bare fists, always melee).
724
+ // - SEED SHAPE: ISceneSeedJson.securityTier (the SITE's PR, never the
725
+ // campaign job number), IPlayerSeedJson.kit / lieutenant. The
726
+ // generator declares the tier from a closed 0-6 table, clamps it
727
+ // against the job number, and assigns a kit to every hostile
728
+ // deterministically -- the model never writes a slug.
729
+ // - THE PARTY NUDGE IS THE LIEUTENANT (p.380-381): a party that
730
+ // outguns the tier draws the printed lieutenant, and the group's PR
731
+ // and Group Edge read one higher. Never a kit from a tier above.
732
+ // - GROUP EDGE (p.380) on the CombatEncounter keyed by kit; MORALE
733
+ // (p.379-380) breaks a team to surrender at the book's casualty
734
+ // thresholds, PR 5-6 never. npc-combat-brain.ts is the gamemaster.
735
+ // - EPHEMERALS: gangers are the PR 1 kit, sweep officers the PR 3
736
+ // kit with a real Biometric Reader; bystanders stay unarmed.
737
+ // Scene.removeActor(name, {spill:false}) so a departing body takes
738
+ // its gun with it.
739
+ // - LOOT: worn armor is RUINED on a kill (Item.ruined -- rated 0,
740
+ // worth nothing, saved); guns and commlinks drop intact.
741
+ // - PURGED: the dead `firearms` skill key from the NPC prompt, the
742
+ // ganger constant and the seed doc; a seed still carrying it maps
743
+ // onto pistols/automatics/longarms at the boundary.
744
+ export const ENGINE_VERSION = '1.64.0';
581
745
  //# sourceMappingURL=engine-version.js.map
@@ -28,6 +28,15 @@ export class NPCFactory {
28
28
  npc.setAuthoredAppearance(json.appearance);
29
29
  return npc;
30
30
  }
31
+ /**
32
+ * ONLY FOUR FIELDS CROSS HERE, on purpose. `startSpot`, `guarding`,
33
+ * `hostile`, `kit` and `lieutenant` are applied by scene-factory AFTER
34
+ * the NPC is placed (Steps 3 and 4.04), because each depends on
35
+ * something the constructor cannot see -- the room it stands in, the
36
+ * items heldBy already put in its hands. Adding one of them to this
37
+ * config would silently reorder it ahead of that. (`startingItems` is
38
+ * dropped too, and documented as ignored for NPCs in repro-scene.ts.)
39
+ */
31
40
  convertPlayerSeedToConstructorConfig(seed) {
32
41
  if (!seed.name) {
33
42
  throw new Error(`Cannot create NPC without a name`);
@@ -40,8 +49,35 @@ export class NPCFactory {
40
49
  playerName: seed.name,
41
50
  lifestyleChoices: seed.lifestyleChoices,
42
51
  startLocation,
43
- combat: seed.combat
52
+ combat: seed.combat ? { ...seed.combat, skills: splitLegacySkills(seed.combat.skills) } : undefined,
44
53
  };
45
54
  }
46
55
  }
56
+ /**
57
+ * A SEED STILL SPEAKING THE OLD SKILL NAMES (skills.ts, stage 3): the
58
+ * same group-purchase reading persistence.ts applies to saves -- a
59
+ * condensed `firearms: N` meant "good with guns, all of them", so it
60
+ * lands as Pistols, Automatics and Longarms at N (p.88) where none was
61
+ * set; `athletics: N` lands as Gymnastics, Running and Swimming. The
62
+ * retired keys are dropped so they cannot ride into `Player.skills` as
63
+ * dead entries. Mapped, never merely deleted: a "sharpshooter" the
64
+ * model wrote with the old key was a sharpshooter, not a nobody.
65
+ */
66
+ export function splitLegacySkills(skills) {
67
+ if (!skills)
68
+ return undefined;
69
+ const out = { ...skills };
70
+ const spread = (legacy, onto) => {
71
+ const n = out[legacy];
72
+ if (n === undefined)
73
+ return;
74
+ if (n > 0)
75
+ for (const key of onto)
76
+ out[key] ??= n;
77
+ delete out[legacy];
78
+ };
79
+ spread('firearms', ['pistols', 'automatics', 'longarms']);
80
+ spread('athletics', ['gymnastics', 'running', 'swimming']);
81
+ return out;
82
+ }
47
83
  //# sourceMappingURL=npc-factory.js.map
@@ -4,6 +4,7 @@ import { Logger } from '../utilities/logger.js';
4
4
  import { catalogAugmentations } from '../utilities/catalog.js';
5
5
  import { isOutdoorsPlace } from '../utilities/outdoors.js';
6
6
  import { clampDeviceKind } from '../utilities/affordances.js';
7
+ import { clampSecurityTier, expectedStrengthFor, kitSlugForTier, maxSecurityTierFor, partyDrawsLieutenant } from '../npc-kits.js';
7
8
  /**
8
9
  * CHUNKED scene generation (see SceneSeedGenerator.generate for the
9
10
  * pipeline that drives this module). One monolithic whole-scene completion
@@ -195,6 +196,73 @@ function repairNote(previousError) {
195
196
  export function partyHeadcount(crew) {
196
197
  return (crew?.length ?? 0) + 1;
197
198
  }
199
+ /**
200
+ * HOW HARD THIS TABLE HITS, per head, on the scale npc-kits.ts
201
+ * expectedStrengthFor uses for a grunt (attack pool + defense pool).
202
+ *
203
+ * REUSES THE NUMBERS THAT ALREADY EXIST rather than inventing a third
204
+ * formula: the leader's figure is exactly the "Combat calibration"
205
+ * line buildPlayerDossier prints (getAttackPool + getDefensePool), and
206
+ * a crew member's is the body + agility + logic + magic sum the hiring
207
+ * price already reads (Game.spawnCrew's asking cut), scaled onto the
208
+ * same axis. Averaged per head, because the lieutenant question is
209
+ * "does this party outclass the tier", not "how many of them are there"
210
+ * -- head count already scales the OPPOSITION COUNT (Scope, above).
211
+ *
212
+ * A dossier player that is not a real Player (the tests' stubs, a
213
+ * server-side draft with no engine handle) contributes nothing and the
214
+ * crew alone decides; no player and no crew is 0, which never draws a
215
+ * lieutenant.
216
+ */
217
+ export function partyStrength(player, crew) {
218
+ const heads = [];
219
+ const p = player;
220
+ if (p && typeof p.getAttackPool === 'function' && typeof p.getDefensePool === 'function') {
221
+ heads.push(p.getAttackPool() + p.getDefensePool());
222
+ }
223
+ for (const m of crew ?? []) {
224
+ const c = m.combat;
225
+ heads.push(1.5 * ((c.body ?? 3) + (c.agility ?? 3) + (c.logic ?? 3) + (c.magic ?? 0)));
226
+ }
227
+ if (heads.length === 0)
228
+ return 0;
229
+ return heads.reduce((s, h) => s + h, 0) / heads.length;
230
+ }
231
+ /**
232
+ * SETTLE WHO GUARDS THIS PLACE (npc-kits.ts, SR5 p.379-385). Called once
233
+ * per accepted skeleton, after validateSkeleton and before the detail
234
+ * passes: clamps the declared securityTier against the campaign job
235
+ * number (a job #1 does not draw elite corporate security however the
236
+ * model felt about it), and decides whether the site posts its
237
+ * lieutenant -- p.380-381's own mechanism for a tougher group, and the
238
+ * whole of how opposition scales to a strong party. The lieutenant is
239
+ * the hostile guarding the prize's room if there is one, else the first
240
+ * hostile. Returns the notes for the generation log.
241
+ */
242
+ export function settleSecurity(skeleton, campaignTier, player, crew) {
243
+ const notes = [];
244
+ const declared = skeleton.securityTier;
245
+ const settled = clampSecurityTier(declared, campaignTier);
246
+ if (declared !== settled) {
247
+ notes.push(`securityTier ${declared === undefined ? 'absent' : JSON.stringify(declared)} -> ${settled} (job #${campaignTier} fields at most PR ${maxSecurityTierFor(campaignTier)}).`);
248
+ }
249
+ skeleton.securityTier = settled;
250
+ const hostiles = skeleton.npcs.filter(n => n.name !== skeleton.client && n.hostile === true);
251
+ if (hostiles.length === 0) {
252
+ delete skeleton.lieutenant;
253
+ return notes;
254
+ }
255
+ const strength = partyStrength(player, crew);
256
+ if (!partyDrawsLieutenant(strength, settled)) {
257
+ delete skeleton.lieutenant;
258
+ return notes;
259
+ }
260
+ const prizeRoom = skeleton.items.find(i => i.name === skeleton.winCondition?.item)?.room;
261
+ const pick = hostiles.find(n => prizeRoom !== undefined && n.startLocation === prizeRoom) ?? hostiles[0];
262
+ skeleton.lieutenant = pick.name;
263
+ notes.push(`party strength ${strength.toFixed(1)} outguns PR ${settled} (expects ~${expectedStrengthFor(settled)}) -- ${pick.name} is the lieutenant.`);
264
+ return notes;
265
+ }
198
266
  function buildTeamBlock(crew) {
199
267
  if (!crew || crew.length === 0)
200
268
  return '';
@@ -478,7 +546,19 @@ ${JSON_ONLY}
478
546
  // again more per job after that
479
547
  "rewardItem"?: string // optional bonus: an items[].name with role "reward"
480
548
  },
481
- "client": string // the npcs[].name of the run's CLIENT -- see CLIENT below
549
+ "client": string, // the npcs[].name of the run's CLIENT -- see CLIENT below
550
+ "securityTier": 0|1|2|3|4|5|6 // WHO GUARDS THIS PLACE. Pick from the SITE, not the payout,
551
+ // and use EXACTLY one of these integers:
552
+ // 0 street thugs, squatters, a mall cop
553
+ // 1 a gang's block, street scum holding a corner
554
+ // 2 corporate security at an ordinary office or warehouse
555
+ // 3 a police patrol, a licensed private force
556
+ // 4 an organized-crime crew on its own ground
557
+ // 5 elite corporate security -- a real corp facility
558
+ // 6 elite special forces -- military; needs a real reason
559
+ // Every "hostile" npc is armed and armored by the engine
560
+ // from this tier's statblock; you write WHO they are, the
561
+ // engine writes WHAT they carry.
482
562
  }
483
563
 
484
564
  CLIENT: every run has a CLIENT -- "a Mr. Johnson", the employer's deniable face, the person
@@ -646,17 +726,22 @@ For EVERY npc in the skeleton, expand its "concept":
646
726
  will refuse it and the player will burn a run trusting it. An NPC may say that a code
647
727
  EXISTS, who holds it, or where it's written down -- never what it is. The engine hands
648
728
  the real pass to whoever genuinely holds it, at runtime (see npc.ts).
649
- - "combat": OPTIONAL attribute block -- omit for anyone who'd never fight. Each attribute
650
- 1-8 (soft civilian 1-2, street-level 3-4, hardened enforcer 5-6, elite 7-8):
729
+ - "combat": OPTIONAL attribute block -- omit for anyone who'd never fight, and OMIT IT FOR
730
+ EVERY HOSTILE: the engine outfits opposition from the site's security tier (the book's
731
+ own Professional Rating statblocks, gear included), and a "combat" block on a hostile is
732
+ ignored. Write one only for a NON-hostile who might still end up in a fight (a bodyguard
733
+ who is not security, a bartender with a shotgun). Each attribute 1-8 (soft civilian 1-2,
734
+ street-level 3-4, hardened enforcer 5-6, elite 7-8):
651
735
  { "body", "agility", "reaction", "combatSkill", "strength"?, "willpower"?, "logic"?,
652
736
  "intuition"?, "charisma"?, "magic"?, "resonance"?, "adept"?,
653
- "skills"?: { "firearms"?, "blades"?, "clubs"?, "unarmed"?, "sneaking"?, "perception"?, "palming"?,
654
- "hacking"?, "cybercombat"?, "electronic-warfare"?, "hardware"?, "locksmith"?, "computer"?,
655
- "negotiation"?, "con"?, "intimidation"?, "etiquette"?, "leadership"?, "performance"?, "disguise"?,
656
- "medicine"?, "assensing"?, "counterspelling"?, "conjuring"?, "gunnery"? } }
657
- "magic" 3-6 ONLY for a genuinely Awakened concept; give combat blocks to anyone the story
658
- expects in a firefight. "skills" is
659
- optional flavor for defined concepts (a sharpshooter's "firearms": 6).
737
+ "skills"?: { "pistols"?, "automatics"?, "longarms"?, "blades"?, "clubs"?, "unarmed"?, "sneaking"?,
738
+ "perception"?, "palming"?, "hacking"?, "cybercombat"?, "electronic-warfare"?, "hardware"?,
739
+ "locksmith"?, "computer"?, "negotiation"?, "con"?, "intimidation"?, "etiquette"?,
740
+ "leadership"?, "performance"?, "disguise"?, "medicine"?, "assensing"?, "counterspelling"?,
741
+ "conjuring"?, "gunnery"? } }
742
+ "magic" 3-6 ONLY for a genuinely Awakened concept. "skills" is optional flavor for defined
743
+ concepts (a sharpshooter's "longarms": 6). There is no generic gun skill: guns are "pistols",
744
+ "automatics" (SMGs, machine pistols, assault rifles) or "longarms" (rifles, shotguns).
660
745
 
661
746
  ${JSON_ONLY}
662
747
  [ { "name": string, "description": string, "dialogue": string[], "combat"?: { ... } } ]
@@ -2605,9 +2690,11 @@ export function assembleScene(skeleton, details, playerName) {
2605
2690
  // from the exit they hold, so moving one would leave `guarding: true`
2606
2691
  // with nothing behind it. See factories/npc-spread.ts.
2607
2692
  const spreadStations = spreadStationedNpcs(npcStation, new Map(rooms.map(r => [r.name, (r.spots ?? []).map(s => s.name)])), name => guardPost.has(name));
2693
+ const tierKit = skeleton.securityTier === undefined ? undefined : kitSlugForTier(skeleton.securityTier);
2608
2694
  const npcs = skeleton.npcs.map(npc => {
2609
2695
  const detail = npcDetail.get(npc.name);
2610
2696
  const station = spreadStations.get(npc.name);
2697
+ const kit = npc.hostile === true && npc.name !== skeleton.client ? tierKit : undefined;
2611
2698
  const roomSpots = (rooms.find(r => r.name === npc.startLocation)?.spots ?? []);
2612
2699
  const startSpot = station && station.room === npc.startLocation && roomSpots.some(s => s.name === station.spot)
2613
2700
  ? station.spot
@@ -2630,8 +2717,18 @@ export function assembleScene(skeleton, details, playerName) {
2630
2717
  // other would put the flag on every checkpoint clerk in the
2631
2718
  // game, which is the failure this feature is built to avoid.
2632
2719
  hostile: npc.hostile === true || undefined,
2720
+ // WHAT THEY CARRY (npc-kits.ts, catalog v21): every hostile is
2721
+ // outfitted from the site's security tier, deterministically --
2722
+ // the model never writes a kit slug, because a slug it invented
2723
+ // is a whole failure class (itemFromCatalog throws on one) and
2724
+ // the tier is the one number it was asked for. THE KIT IS THE
2725
+ // STATLINE: a `combat` block the model wrote for a guard is
2726
+ // dropped here, not merged, so a dead key cannot ride in beside
2727
+ // the book's numbers. Non-hostiles keep theirs.
2728
+ ...(kit ? { kit } : {}),
2729
+ ...(kit && skeleton.lieutenant === npc.name ? { lieutenant: true } : {}),
2633
2730
  lifestyleChoices: [],
2634
- combat: detail?.combat,
2731
+ combat: kit ? undefined : detail?.combat,
2635
2732
  },
2636
2733
  };
2637
2734
  });
@@ -2772,6 +2869,7 @@ export function assembleScene(skeleton, details, playerName) {
2772
2869
  completionMessage: skeleton.completionMessage,
2773
2870
  },
2774
2871
  client: skeleton.client,
2872
+ ...(skeleton.securityTier !== undefined ? { securityTier: skeleton.securityTier } : {}),
2775
2873
  };
2776
2874
  }
2777
2875
  /**