@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
@@ -14,6 +14,7 @@ import { migrateLegacySeed } from './seed-migration.js';
14
14
  import { Device } from '../models/device.js';
15
15
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
16
16
  import { adoptSeededIce } from '../utilities/ic-actors.js';
17
+ import { NPC_EQUIP_CATEGORIES, outfitNpcBySlug } from '../npc-kits.js';
17
18
  /**
18
19
  * How many AI enrichment calls may be in flight at once.
19
20
  *
@@ -512,27 +513,58 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
512
513
  logger.error(`Scene "${json.name}": the door ${String(direction)} of ${room.name} was locked by "${wanted ?? '(nothing)'}", which no device holds and no item matches -- it ships unlocked rather than as a wall.`);
513
514
  }
514
515
  }
516
+ // Step 4.04: NPCs get their kit (npc-kits.ts, SR5 p.379-385, catalog
517
+ // v21). AFTER "heldBy" has landed (Step 4.5 above -- the numbering is
518
+ // historical, the source order is what runs) and BEFORE the equip
519
+ // step below, on purpose: an author who put a weapon in this NPC's
520
+ // hands with heldBy is the author speaking, and the kit yields its
521
+ // own weapon to it (respectCarried) while its armor still layers.
522
+ // The seed's `kit` is applied here rather than in npc-factory for
523
+ // the same reason startSpot/guarding/hostile are applied at Step 3
524
+ // instead of the constructor: the factory drops every field but four,
525
+ // and the ordering guarantee above only holds in THIS loop.
526
+ for (const npcJson of json.npcs ?? []) {
527
+ if (!npcJson.player.kit)
528
+ continue;
529
+ const npc = scene.getActor(npcJson.player.name);
530
+ if (!npc)
531
+ continue;
532
+ outfitNpcBySlug(npc, npcJson.player.kit, {
533
+ lieutenant: npcJson.player.lieutenant === true,
534
+ respectCarried: true,
535
+ });
536
+ }
515
537
  // Step 4.05: NPCs wear their gear. "heldBy" only puts items in an NPC's
516
538
  // inventory, but combat pools (see Player.getWeaponProfile /
517
539
  // getArmorValue) read from EQUIPPED slots -- without this, an enforcer
518
540
  // "holding" an assault rifle and an armored jacket would fight
519
541
  // bare-fisted in street clothes. Only weapon/armor categories are
520
- // auto-equipped: plot items (datachips, books, keys) stay in inventory
521
- // where "give"/death-drops expect them. Failures are non-fatal -- a
522
- // second weapon just stays holstered in inventory.
523
- const combatCategories = new Set([
524
- Category.Weapon, Category.MeleeWeapon, Category.RangedWeapon,
525
- Category.Armor, Category.BodyArmor, Category.ArmorClothing, Category.Shield,
526
- Category.Helmet, Category.ArmArmor, Category.LegArmor, Category.Boots, Category.Gloves,
527
- ]);
542
+ // auto-equipped (NPC_EQUIP_CATEGORIES -- the one list, shared with the
543
+ // ephemeral governor): plot items (datachips, books, keys) stay in
544
+ // inventory where "give"/death-drops expect them. Failures are
545
+ // non-fatal -- a second weapon just stays holstered in inventory.
546
+ //
547
+ // FIRST WEAPON WINS (2026-09-15, the day kits gave every guard two).
548
+ // `equip` SWAPS, so the unguarded loop this used to be would have
549
+ // ended every PR 1 ganger holding the knife with the Browning
550
+ // holstered -- the exact bug archetypes.ts:outfitArchetype documents
551
+ // for runners. It never fired here only because no seeded NPC ever
552
+ // carried two weapons. Armor still layers, so only weapons are
553
+ // guarded.
554
+ const weaponCategories = new Set([Category.Weapon, Category.MeleeWeapon, Category.RangedWeapon]);
528
555
  for (const npcJson of json.npcs ?? []) {
529
556
  const npc = scene.getActor(npcJson.player.name);
530
557
  if (!npc)
531
558
  continue;
532
559
  for (const item of [...npc.inventory.getAllItems()]) {
533
- if (combatCategories.has(item.category)) {
560
+ if (!NPC_EQUIP_CATEGORIES.has(item.category))
561
+ continue;
562
+ if (weaponCategories.has(item.category) && npc.getCarriedWeapon())
563
+ continue;
564
+ try {
534
565
  npc.equip(item);
535
566
  }
567
+ catch { /* nothing to slot it into -- stays holstered */ }
536
568
  }
537
569
  }
538
570
  // Step 4.1: Add Starting Items for the Player (skipped when continuing
@@ -9,7 +9,7 @@ import { GenerationCapture } from '../utilities/generation-capture.js';
9
9
  import { fetchCanonContext } from '../utilities/canon-lore.js';
10
10
  import { providerUnavailable } from '../utilities/draft-failure-note.js';
11
11
  import { SceneSynthesizer } from './scene-factory.js';
12
- import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, CHUNK_SCHEMAS, unwrapChunk, VAULT_HOST_RATING, } from './scene-chunks.js';
12
+ import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, settleSecurity, assembleScene, classifyRepair, CHUNK_SCHEMAS, unwrapChunk, VAULT_HOST_RATING, } from './scene-chunks.js';
13
13
  // The fixer name convention and the player dossier live with the prompt
14
14
  // builders now (see scene-chunks.ts); re-exported so the existing
15
15
  // import sites (call.ts, game.ts) keep working unchanged.
@@ -241,6 +241,15 @@ export class SceneSeedGenerator {
241
241
  // for a scaled opposition, and this is what makes the aim
242
242
  // stick when the model quietly ignores it.
243
243
  validateSkeleton(skeleton, player, crew);
244
+ // WHO GUARDS THIS PLACE (npc-kits.ts): clamp the declared tier
245
+ // against the job number and post the lieutenant if this table
246
+ // outguns it. After validation, so a rejected skeleton never
247
+ // has its security settled; before the detail passes, so the
248
+ // NPC prompt can be told which entries the kit will dress.
249
+ for (const note of settleSecurity(skeleton, tier, player, crew)) {
250
+ notes.push(note);
251
+ logger.write(`SceneSeedGenerator: ${note}`);
252
+ }
244
253
  GenerationCapture.record({
245
254
  stage: 'skeleton', outcome: 'accepted', pipeline, attempt,
246
255
  prompt, reply, notes,
@@ -12,6 +12,9 @@ import { hostInteriorPanel } from './utilities/grid-view.js';
12
12
  import { DEFAULT_GRID_PROVIDER, PUBLIC_GRID, globalGrid, looseEndGrid } from './utilities/grids.js';
13
13
  import { buildCleanupSeed } from './factories/cleanup-seed.js';
14
14
  import { EditFileCommand, EraseCommand } from './commands/edit-file.js';
15
+ import { EraseMarkCommand } from './commands/erase-mark.js';
16
+ import { SPRITE_POWER_COMMANDS } from './commands/sprite-power.js';
17
+ import { cleanupOutcome } from './utilities/cleanup-errand.js';
15
18
  import { hostsInReach, hostDockLine, gridVicinity } from './utilities/grid-reach.js';
16
19
  import { nameThem, scrubHandles } from './utilities/identity.js';
17
20
  import { dirname } from 'path';
@@ -590,6 +593,19 @@ export default class Game {
590
593
  // `?? []`: hosted-homecoming.test.ts drives a Game off its prototype,
591
594
  // where field initialisers never ran.
592
595
  for (const le of this._looseEnds ?? []) {
596
+ // A HIRED SCRUB RESOLVES BEFORE THE DESK DOES (cleanup-errand.ts).
597
+ // You paid at the hub and did not get to watch; this is where the
598
+ // decker reports back, and it happens whatever grace is left --
599
+ // that is what the nuyen bought.
600
+ if (le.hired) {
601
+ const done = this.settleHiredCleanup(le);
602
+ lines.push(...done.lines);
603
+ // A botched scrub leaves the footage exactly where it was, and
604
+ // the grace clock keeps running -- you bought an attempt.
605
+ if (done.keep)
606
+ kept.push(done.record);
607
+ continue;
608
+ }
593
609
  const left = le.homecomingsLeft - 1;
594
610
  if (left > 0) {
595
611
  kept.push({ ...le, homecomingsLeft: left });
@@ -607,6 +623,60 @@ export default class Game {
607
623
  }
608
624
  return lines;
609
625
  }
626
+ /**
627
+ * A HIRED SCRUB REPORTS BACK (utilities/cleanup-errand.ts). The
628
+ * decker's test is rolled HERE, not at hire time: you paid for a
629
+ * result you do not get to watch, and the delay is the point.
630
+ *
631
+ * The pool was banked on the record at hire (the fixer's Connection +
632
+ * Loyalty) so a loyalty shift in between cannot retroactively change a
633
+ * job already bought. It rolls against the host's rating -- the same
634
+ * ice the runner would have had to get through.
635
+ */
636
+ settleHiredCleanup(le) {
637
+ const hired = le.hired;
638
+ const mine = rollPool(Math.max(1, hired.pool));
639
+ const host = rollPool(Math.max(1, le.host.rating));
640
+ const outcome = cleanupOutcome(mine.hits - host.hits, mine.criticalGlitch);
641
+ Logger.getInstance().write(`Cleanup errand "${le.id}": ${hired.decker} via ${hired.fixer}, ${mine.hits} v ${host.hits} -> ${outcome}.`);
642
+ Logger.getInstance().meta(`Cleanup errand -- ${hired.decker} (${hired.fixer}'s reach): ${formatRoll(mine)} v. ${le.site}'s host: ${formatRoll(host)}`);
643
+ if (outcome === 'botched') {
644
+ // The clock keeps running, and the hire is spent: the record goes
645
+ // back to being yours to deal with.
646
+ const left = le.homecomingsLeft - 1;
647
+ const stillOpen = left > 0;
648
+ if (!stillOpen) {
649
+ const stained = this.player.addPublicAwareness(`caught on the cameras at "${le.site}" during "${le.jobName}"`);
650
+ return {
651
+ lines: [`\n{red-fg}${hired.fixer} calls, short: "${hired.decker} got burned off ${le.site}'s host. Didn't get it." And then the desk reviews the feeds anyway.{/red-fg}${stained ? ` (+1 Public Awareness, now ${this.player.publicAwareness})` : ''}`],
652
+ keep: false,
653
+ record: le,
654
+ };
655
+ }
656
+ return {
657
+ lines: [`\n{yellow-fg}${hired.fixer} calls, short: "${hired.decker} got burned off ${le.site}'s host. Didn't get it -- and no, you don't get the nuyen back."{/yellow-fg}${hint(` (The footage is still up. Hire again, or "jack in" and "hop ${le.site}" yourself.)`)}`],
658
+ keep: true,
659
+ record: { ...le, homecomingsLeft: left, hired: undefined },
660
+ };
661
+ }
662
+ const leverage = outcome === 'leverage';
663
+ return {
664
+ lines: [`\n{light-blue-fg}${hired.fixer} pings you two days on: "${le.site}'s feeds from '${le.jobName}' are a loop of an empty hallway. You were never there."{/light-blue-fg}${leverage ? `\n{yellow-fg}A beat later, a second message from a number you don't know: a single still of your face, and nothing else. ${hired.decker} kept a copy.{/yellow-fg}` : ''}`],
665
+ keep: false,
666
+ record: le,
667
+ };
668
+ }
669
+ /**
670
+ * HIRE A SHADOW DECKER for an open loose end (commands/crew.ts,
671
+ * "hire cleanup"). Debits the nuyen and stamps the record; the job
672
+ * itself resolves at the next homecoming (settleHiredCleanup).
673
+ */
674
+ hireCleanup(le, hired) {
675
+ const idx = this._looseEnds.findIndex(x => x.id === le.id);
676
+ if (idx >= 0)
677
+ this._looseEnds[idx] = { ...this._looseEnds[idx], hired };
678
+ Logger.getInstance().write(`Cleanup hired: ${hired.decker} via ${hired.fixer} for "${le.id}" at ${hired.price} nuyen.`);
679
+ }
610
680
  /** The grids "hop" lists for the open loose ends -- from the hub only. */
611
681
  looseEndGrids() {
612
682
  if (!this.hasHub || this.scene !== this._hubScene)
@@ -1689,7 +1759,11 @@ export default class Game {
1689
1759
  name: `${item.name} (autopilot)`,
1690
1760
  description: `${this.player.name}'s drone running its dog-brain autopilot: it follows them, watches, and ${armed ? 'shoots what threatens them' : 'has cameras where guns would go -- it never attacks'}.`,
1691
1761
  kind: 'drone',
1692
- combat: { body: item.droneBody, agility: pilot, reaction: pilot, logic: pilot, intuition: pilot, willpower: pilot, strength: 1, charisma: 1, skills: { perception: pilot, ...(armed ? { firearms: pilot } : {}) } },
1762
+ // `firearms` is not a skill (skills.ts, stage 3) and this shell
1763
+ // carried it as a dead key until 2026-09-15: an armed drone rolls
1764
+ // Gunnery (p.131, and what getAttackPool reads for a jumped-in
1765
+ // frame) and, for a mounted gun's own skill, the split three.
1766
+ combat: { body: item.droneBody, agility: pilot, reaction: pilot, logic: pilot, intuition: pilot, willpower: pilot, strength: 1, charisma: 1, skills: { perception: pilot, ...(armed ? { gunnery: pilot, automatics: pilot, pistols: pilot, longarms: pilot } : {}) } },
1693
1767
  room, plane: 'meat', force: pilot, boundDevice: item,
1694
1768
  });
1695
1769
  // Take-command weight checks (Player.addInventory) enforce this cap
@@ -3001,6 +3075,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3001
3075
  CommandFactory.registerCommand('wipe', EraseCommand);
3002
3076
  // MAtrix Recognition Keys (canon p.235-236).
3003
3077
  CommandFactory.registerCommand('mark', MarkCommand);
3078
+ CommandFactory.registerCommand('unmark', EraseMarkCommand);
3079
+ // SPRITE POWERS (p.256-257): one verb per power, reached as
3080
+ // "order <sprite> <power> <target>" -- see commands/sprite-power.ts.
3081
+ for (const [key, cls] of Object.entries(SPRITE_POWER_COMMANDS)) {
3082
+ CommandFactory.registerCommand(key, cls);
3083
+ }
3004
3084
  // Camera feeds you own (surveillance).
3005
3085
  CommandFactory.registerCommand('snoop', SnoopCommand);
3006
3086
  // The cable into a slaved camera (canon p.232/233) -- "splice" is
@@ -3383,8 +3463,20 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3383
3463
  const where = this._resumedAt && this._resumedAt !== this._homeRoom
3384
3464
  ? `You're back at the ${this._resumedAt.name}${this.player.atSpot ? `, by the ${this.player.atSpot}` : ''} -- right where you left off.`
3385
3465
  : 'The cot remembers your shape.';
3466
+ // THE NAG HAS TO NAME A ROUTE THIS RUNNER CAN TAKE (user
3467
+ // 2026-09-15). "jack in, then hop" is closed to anyone without a
3468
+ // deck and a direct neural interface -- which is most runners --
3469
+ // so a street samurai was told about a consequence and handed no
3470
+ // lever. The errand (utilities/cleanup-errand.ts) is that lever,
3471
+ // and it is the advice a non-decker gets.
3472
+ const hiredNote = this._looseEnds.some(le => le.hired)
3473
+ ? ` Somebody is already on one of them.`
3474
+ : '';
3475
+ const route = this.player.canReachMatrix()
3476
+ ? `"jack in", then "hop" -- the site's grid is listed while the footage is still up there. Or "ask <contact> about cleanup" to buy the scrub instead.`
3477
+ : `"ask <contact> about cleanup" -- you can't ride the grid, but somebody you know knows somebody who can.`;
3386
3478
  const looseNote = this._looseEnds.length > 0
3387
- ? `\n\n{yellow-fg}${this._looseEnds.map(le => `${le.site} still has your face from "${le.jobName}".`).join(' ')}{/yellow-fg}${hint(` ("jack in", then "hop" -- the site's grid is listed while the footage is still up there.)`)}`
3479
+ ? `\n\n{yellow-fg}${this._looseEnds.map(le => `${le.site} still has your face from "${le.jobName}".`).join(' ')}${hiredNote}{/yellow-fg}${hint(` (${route})`)}`
3388
3480
  : '';
3389
3481
  return `Welcome back, ${this.player.name}. ${where}\n ${this.scene.story}${pendingNote}${looseNote}`;
3390
3482
  }
@@ -6647,7 +6739,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6647
6739
  // players" / "crew street"); the bare verb still answers quietly.
6648
6740
  { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
6649
6741
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
6650
- { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6742
+ { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['unmark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6651
6743
  // The Emerged get their own shelf (player request: "there MUST be
6652
6744
  // resonance" -- matrix is the place, Resonance is the talent).
6653
6745
  // `sustain` shelves here and not under magic: the command is
@@ -77,6 +77,14 @@ export class Host {
77
77
  /** Kinds crashed this turn; the host relaunches them next turn (p.355-356). */
78
78
  relaunchQueue = [];
79
79
  patrolCountdown;
80
+ /**
81
+ * SUPPRESSION (SR5 p.257, utilities/sprite-power-actions.ts): a sprite
82
+ * inside the host is arguing with its launch paths, so IC it brings up
83
+ * arrives (Level / 2) Combat Turns late -- and "delayed IC can't act or
84
+ * be targeted" while it waits. Counted down by hostTurnStart. Session
85
+ * state, like the rest of the fight.
86
+ */
87
+ icLaunchDelay = 0;
80
88
  /** A haven (jackpoint.ts): never hunts, shows no rating, forgets cracks. */
81
89
  sanctioned = false;
82
90
  /**
@@ -49,6 +49,40 @@ export class Item extends AbstractItem {
49
49
  // work (rest.ts). A reboot does NOT clear it; that is the whole
50
50
  // difference from `jammed`.
51
51
  bricked = false;
52
+ /**
53
+ * RUINED (Room.layOutBody, catalog v21 NPC kits): armor that soaked
54
+ * the rounds that killed its wearer. Rated 0 (armorValue, armorBonus)
55
+ * and worth nothing (commerce.ts priceOf); it drops, so the runner
56
+ * sees what the guard was wearing, and it is not a free set of full
57
+ * body armor -- the plates are what stopped the rounds. Saved.
58
+ */
59
+ ruined = false;
60
+ /** Mark this piece ruined, and say so on it. Idempotent. */
61
+ ruin(how) {
62
+ if (this.ruined)
63
+ return;
64
+ this.ruined = true;
65
+ this.description = `${this.description} RUINED -- ${how}.`;
66
+ }
67
+ /**
68
+ * SPRITE POWERS ON A FILE (SR5 p.256-257,
69
+ * utilities/sprite-power-actions.ts). Matrix-plane items only; all
70
+ * three are session state, like the marks and sightings they sit
71
+ * beside -- a sprite's work lasts as long as the sprite does.
72
+ *
73
+ * CAMOUFLAGE: folded inside another file, so a Matrix Search walks
74
+ * past it (commands/search.ts) and only a Matrix Perception hunting
75
+ * this file turns it up -- the sprite included.
76
+ */
77
+ concealedBy;
78
+ /** HASH: sealed by a Resonance algorithm only this sprite can undo, for
79
+ * `hashTurns` Combat Turns. Nothing else edits or deletes it while it
80
+ * holds (commands/edit-file.ts). */
81
+ hashedBy;
82
+ hashTurns = 0;
83
+ /** WATERMARK: an invisible tag only Resonance-driven things read -- a
84
+ * technomancer or another sprite. A new one overwrites the old. */
85
+ watermark;
52
86
  _transferable;
53
87
  // New currency-related fields
54
88
  _currencyAmount = 0;
@@ -562,6 +596,8 @@ export class Item extends AbstractItem {
562
596
  * clothing 0. Only the highest worn piece counts (Player.armorBreakdown).
563
597
  */
564
598
  get armorValue() {
599
+ if (this.ruined)
600
+ return 0;
565
601
  const w = this.row?.wearable;
566
602
  if (w?.armor !== undefined)
567
603
  return w.armor;
@@ -579,6 +615,8 @@ export class Item extends AbstractItem {
579
615
  }
580
616
  /** A "+" accessory's bonus (helmet +2, shield +6, p.437-438): the row's, else the class value. */
581
617
  get armorBonus() {
618
+ if (this.ruined)
619
+ return 0;
582
620
  const w = this.row?.wearable;
583
621
  if (w?.armorBonus !== undefined)
584
622
  return w.armorBonus;
@@ -705,6 +705,18 @@ export class NPC extends Player {
705
705
  * "nobody in particular", which every rule reads as no opinion.
706
706
  */
707
707
  faction;
708
+ /**
709
+ * WHAT THIS GRUNT CARRIES AND HOW TRAINED THEY ARE (npc-kits.ts, SR5
710
+ * p.379-385): the kit row's slug, its Professional Rating, and
711
+ * whether this one is the team's lieutenant (p.380-381). Set by
712
+ * outfitNpcKit and read by the Combat Turn for Group Edge and morale.
713
+ * Absent on every NPC built from an attribute block alone, which is
714
+ * every NPC there was before catalog v21. Session state, never saved:
715
+ * a seeded NPC is re-outfitted from its seed on every synthesis.
716
+ */
717
+ kit;
718
+ professionalRating;
719
+ lieutenant;
708
720
  /**
709
721
  * SET ONLY BY THE EPHEMERAL GOVERNOR (utilities/ephemeral.ts): this
710
722
  * body is a passing face, not a character. Its presence trims the
@@ -735,6 +747,14 @@ export class NPC extends Player {
735
747
  }
736
748
  boundDevice;
737
749
  companionKind;
750
+ /**
751
+ * ELECTRON STORM (SR5 p.257, utilities/sprite-power-actions.ts): the
752
+ * persona this sprite is holding a storm open on. It burns again every
753
+ * action the sprite spends sustaining it, and "if the sprite takes any
754
+ * Matrix damage, all of its electron storms end immediately" -- which
755
+ * is why this is cleared on damage rather than on a timer.
756
+ */
757
+ sustainedStorm;
738
758
  /**
739
759
  * Runs one command AS this NPC through its own scene-bound registry --
740
760
  * the deterministic control surface for "order <companion> <command>"
@@ -621,6 +621,21 @@ export class Player extends AbstractPlayer {
621
621
  // against me (hack.ts). My "reboot" wipes them all; a placer's own
622
622
  // reboot/jack-out/convergence wipes just theirs (utilities/marks.ts).
623
623
  panMarksBy = new Map();
624
+ /**
625
+ * COOKIE (SR5 p.256-257, utilities/sprite-power-actions.ts): sprite
626
+ * name -> net hits, which is the DEPTH the report carries when it ships
627
+ * home (one hit a bare outline, four or more a detailed report). The
628
+ * carrier does not know it is there; a Matrix Perception on them finds
629
+ * it. Session state, like the marks above -- a sprite's work lasts as
630
+ * long as the sprite.
631
+ */
632
+ cookiedBy = new Map();
633
+ /**
634
+ * DIAGNOSTICS (p.257): the Teamwork bonus a machine sprite is holding
635
+ * on ONE device -- "it takes the sprite's entire attention", so there
636
+ * is at most one, and giving that sprite any other order drops it.
637
+ */
638
+ spriteAssist;
624
639
  // OVERWATCH SCORE (GOD, canon p.231-232, see utilities/overwatch.ts):
625
640
  // evidence tally of this actor's illegal Matrix actions. Starts at 0
626
641
  // on a fresh boot, grows with every Attack/Sleaze action (and a
@@ -704,6 +719,27 @@ export class Player extends AbstractPlayer {
704
719
  return true;
705
720
  return this.dniSource() !== null;
706
721
  }
722
+ /**
723
+ * COULD THIS RUNNER RIDE THE GRID AT ALL -- the question commands/jack.ts
724
+ * answers in three separate refusals, asked once so ADVICE can be
725
+ * written for the right audience.
726
+ *
727
+ * Added for the loose-end nag (user 2026-09-15): the hub told every
728
+ * runner to "jack in, then hop" and erase the footage, including a
729
+ * street samurai with no deck and no datajack, who therefore got a
730
+ * consequence with no lever. A line that recommends a route has to
731
+ * know whether the reader can take it.
732
+ *
733
+ * Deliberately NOT the same as "may jack in right now" -- a bricked
734
+ * deck is a repair, not a disqualification, and this is about what the
735
+ * character can ever do rather than what they can do this minute.
736
+ */
737
+ canReachMatrix() {
738
+ if (this.isTechnomancer())
739
+ return true;
740
+ const deck = this.getCyberdeck() ?? this.getAnyCyberdeck();
741
+ return deck !== undefined && this.hasDirectNeuralInterface();
742
+ }
707
743
  /** Failed reads of a clue, by item name, for p.49's Trying Again
708
744
  * (-2 per retry). In memory only: a save and reload is the "break"
709
745
  * the rule says clears it. See utilities/clue-reading.ts. */
@@ -1173,6 +1209,14 @@ export class Player extends AbstractPlayer {
1173
1209
  }
1174
1210
  /** Routes Matrix combat damage into the jacked-in deck's hardware track. */
1175
1211
  takeMatrixDamage(boxes) {
1212
+ // ELECTRON STORM ENDS WHEN ITS SPRITE IS HURT (SR5 p.257: "if the
1213
+ // sprite takes any Matrix damage, all of its electron storms end
1214
+ // immediately"). A sprite is an NPC, which is a Player, so the rule
1215
+ // lives on the one method every Matrix hit goes through rather than
1216
+ // in each thing that can deal one.
1217
+ const self = this;
1218
+ if (boxes > 0 && self.sustainedStorm)
1219
+ self.sustainedStorm = undefined;
1176
1220
  const deck = this.getAnyCyberdeck();
1177
1221
  const taken = deck ? deck.takeDeckDamage(boxes) : 0;
1178
1222
  // DECK DAMAGE BEATS (web report 2026-09-02: "the damage to the deck is not moving in realtime"):
@@ -124,9 +124,18 @@ export class Room extends AbstractRoom {
124
124
  for (const [slot, item] of Object.entries(group)) {
125
125
  if (!item)
126
126
  continue;
127
- held.addItem(item);
127
+ const piece = item;
128
+ // WORN ARMOR COMES OFF RUINED (2026-09-15, with the NPC kits
129
+ // that put real armor on guards): the jacket stopped the rounds
130
+ // that killed the wearer, and that is what a jacket is for.
131
+ // Guns and commlinks drop intact -- a PR 5 kill is still the
132
+ // Ares Alpha, it is just not a free set of full body armor.
133
+ if (piece.armorValue > 0 || piece.armorBonus > 0) {
134
+ piece.ruin(`it took the rounds that dropped ${actor.name}`);
135
+ }
136
+ held.addItem(piece);
128
137
  group[slot] = null;
129
- this.logger.write(`${actor.name}'s ${slot} falls with the body in ${this.name}.`);
138
+ this.logger.write(`${actor.name}'s ${slot} falls with the body in ${this.name}${piece.ruined ? ' (ruined)' : ''}.`);
130
139
  }
131
140
  }
132
141
  }
@@ -628,7 +628,23 @@ export class Scene extends AbstractScene {
628
628
  this.logger.write(`Actor with name ${actor.name} has been added to the scene.`);
629
629
  this.actors.set(actor.name, actor);
630
630
  }
631
- removeActor(name) {
631
+ /**
632
+ * Take an actor out of the world.
633
+ *
634
+ * `spill` (default true) is whether what they carried lands on the
635
+ * floor. TRUE IS A BODY: a kill has already been stripped by
636
+ * Room.layOutBody, and anything left is the leftover a corpse drops.
637
+ * FALSE IS A DEPARTURE (utilities/ephemeral.ts despawnEphemerals): a
638
+ * ganger who walks off their corner, an officer whose sweep moved on,
639
+ * takes their gun with them. Before kits (catalog v21) ephemerals
640
+ * carried nothing so the distinction cost nothing; the day they got
641
+ * a Browning and an armor vest, walking away from a street corner
642
+ * rained both onto the pavement, per ganger, per block -- and the
643
+ * governor's founding denial is that an ephemeral cannot create
644
+ * obligations. A gun the world has to keep track of after the body
645
+ * is gone is exactly that.
646
+ */
647
+ removeActor(name, opts = {}) {
632
648
  const actor = this.actors.get(name);
633
649
  // Removal is cleanup, not an assertion: a concurrent kill resolution
634
650
  // (or any double-remove) finding the actor already gone is a no-op,
@@ -639,6 +655,10 @@ export class Scene extends AbstractScene {
639
655
  return;
640
656
  }
641
657
  const room = actor.currentLocation;
658
+ const spill = opts.spill !== false;
659
+ if (!spill && room) {
660
+ this.logger.write(`removeActor: ${actor.name} leaves ${room.name} with everything they carried.`);
661
+ }
642
662
  // One consolidated observer line for the whole spill, not one stilted
643
663
  // "on death drops" line per item -- and everything that falls is IN
644
664
  // PLAIN SIGHT (see Room.plainSightItems): the player just watched it
@@ -649,7 +669,7 @@ export class Scene extends AbstractScene {
649
669
  // "counter" that didn't exist in the room (the note had ridden a
650
670
  // crew member's pack from a different scene entirely).
651
671
  const fallSpot = actor.atSpot;
652
- if (room && actor.inventory) {
672
+ if (spill && room && actor.inventory) {
653
673
  const items = actor.inventory.getAllItems();
654
674
  for (const item of items) {
655
675
  const didDrop = actor.inventory.transferItemTo(item.name, room.inventory);
@@ -666,7 +686,7 @@ export class Scene extends AbstractScene {
666
686
  // holstered pistol are exactly the loot a runner expects to strip.
667
687
  // Without this, everything an NPC had equipped would silently vanish
668
688
  // from the world on death.
669
- if (room && actor.equipment) {
689
+ if (spill && room && actor.equipment) {
670
690
  for (const group of Object.values(actor.equipment)) {
671
691
  if (!group)
672
692
  continue;