@maka/maka-cli 5.201.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 (21) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +27 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js +37 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +109 -11
  5. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +41 -9
  6. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +10 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/game.js +5 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +19 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +12 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +11 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +23 -3
  12. package/bundle/typescript/src/commands/game/sideQuest/npc-kits.js +295 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +34 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +87 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/utilities/commerce.js +5 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/utilities/ephemeral.js +17 -3
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +36 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +13 -3
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/street-gang.js +25 -13
  21. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.201.0",
3
+ "version": "5.202.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -715,5 +715,31 @@
715
715
  // somebody happened to look -- the "wrong ability on a sprite is
716
716
  // worse than a missing one" case. It wants the glitch contract in one
717
717
  // place first, and that is its own piece of work.
718
- export const ENGINE_VERSION = '1.63.0';
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';
719
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
  /**
@@ -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,
@@ -1759,7 +1759,11 @@ export default class Game {
1759
1759
  name: `${item.name} (autopilot)`,
1760
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'}.`,
1761
1761
  kind: 'drone',
1762
- 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 } : {}) } },
1763
1767
  room, plane: 'meat', force: pilot, boundDevice: item,
1764
1768
  });
1765
1769
  // Take-command weight checks (Player.addInventory) enforce this cap
@@ -49,6 +49,21 @@ 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
+ }
52
67
  /**
53
68
  * SPRITE POWERS ON A FILE (SR5 p.256-257,
54
69
  * utilities/sprite-power-actions.ts). Matrix-plane items only; all
@@ -581,6 +596,8 @@ export class Item extends AbstractItem {
581
596
  * clothing 0. Only the highest worn piece counts (Player.armorBreakdown).
582
597
  */
583
598
  get armorValue() {
599
+ if (this.ruined)
600
+ return 0;
584
601
  const w = this.row?.wearable;
585
602
  if (w?.armor !== undefined)
586
603
  return w.armor;
@@ -598,6 +615,8 @@ export class Item extends AbstractItem {
598
615
  }
599
616
  /** A "+" accessory's bonus (helmet +2, shield +6, p.437-438): the row's, else the class value. */
600
617
  get armorBonus() {
618
+ if (this.ruined)
619
+ return 0;
601
620
  const w = this.row?.wearable;
602
621
  if (w?.armorBonus !== undefined)
603
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
@@ -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;
@@ -0,0 +1,295 @@
1
+ import { augFor, itemFromCatalog, npcKitFor } from './utilities/catalog.js';
2
+ import { Category } from './types/shared/item-enum.js';
3
+ import { Logger } from './utilities/logger.js';
4
+ // ============================================================================
5
+ // Tiers
6
+ // ============================================================================
7
+ /**
8
+ * WHICH KIT A SITE'S SECURITY TIER FIELDS. The slugs match the shipped
9
+ * rows (maka-cli.com full-catalog.json v21); a row that is missing
10
+ * resolves to no kit, per the file comment.
11
+ */
12
+ export const KIT_FOR_TIER = {
13
+ 0: 'thugs',
14
+ 1: 'gangers',
15
+ 2: 'corp-security',
16
+ 3: 'police-patrol',
17
+ 4: 'organized-crime',
18
+ 5: 'elite-corp-security',
19
+ 6: 'elite-special-forces',
20
+ };
21
+ export function kitSlugForTier(tier) {
22
+ return KIT_FOR_TIER[tier];
23
+ }
24
+ /**
25
+ * THE MOST A JOB OF THIS SIZE PLAUSIBLY FIELDS. `campaignTier` is the
26
+ * job number (buildSkeletonPrompt's `tier`), NOT the security tier --
27
+ * the two words collide and this is the one place they meet. Job #1 is
28
+ * a warehouse, not an arcology: elite corporate security (PR 5) is a
29
+ * tier-3+ problem, and PR 6 special forces never come from a generated
30
+ * draft at all -- a hand-authored seed may declare them.
31
+ */
32
+ export function maxSecurityTierFor(campaignTier) {
33
+ if (campaignTier <= 1)
34
+ return 2;
35
+ if (campaignTier === 2)
36
+ return 3;
37
+ return 5;
38
+ }
39
+ /** What a draft that declared nothing gets: a gang block for job #1, a
40
+ * corporate office for #2, a patrolled site after that. */
41
+ export function defaultSecurityTierFor(campaignTier) {
42
+ if (campaignTier <= 1)
43
+ return 1;
44
+ if (campaignTier === 2)
45
+ return 2;
46
+ return 3;
47
+ }
48
+ /**
49
+ * CLAMP, NEVER REJECT. A model that wrote "securityTier": 9, or "high",
50
+ * or nothing, has still written a usable scene; failing the skeleton
51
+ * over it would spend a real generation retry on a number the engine
52
+ * can repair in one line. Same policy as the keyed-door and keyItem
53
+ * repairs in scene-factory.ts.
54
+ */
55
+ export function clampSecurityTier(raw, campaignTier) {
56
+ const n = typeof raw === 'number' ? raw : Number(raw);
57
+ const base = Number.isFinite(n) ? Math.round(n) : defaultSecurityTierFor(campaignTier);
58
+ return Math.max(0, Math.min(maxSecurityTierFor(campaignTier), base));
59
+ }
60
+ /**
61
+ * ROUGHLY WHAT A RANK-AND-FILE GRUNT OF THIS TIER ROLLS, attack pool
62
+ * plus defense pool, read off the p.381-384 statblocks (Agility +
63
+ * weapon skill + smartlink/toner; Reaction + Intuition/2 + wired
64
+ * reflexes). The party's own figure (scene-chunks.ts partyStrength) is
65
+ * on the same scale, and a party that clears this by a margin draws
66
+ * the tier's lieutenant.
67
+ */
68
+ export function expectedStrengthFor(securityTier) {
69
+ const table = { 0: 11, 1: 12, 2: 13, 3: 12, 4: 16, 5: 25, 6: 29 };
70
+ return table[Math.max(0, Math.min(6, securityTier))] ?? 12;
71
+ }
72
+ /** The margin past expectedStrengthFor at which a site posts its lieutenant. */
73
+ export const LIEUTENANT_MARGIN = 3;
74
+ export function partyDrawsLieutenant(partyStrength, securityTier) {
75
+ return partyStrength > expectedStrengthFor(securityTier) + LIEUTENANT_MARGIN;
76
+ }
77
+ // ============================================================================
78
+ // Group Edge and morale (p.379-380)
79
+ // ============================================================================
80
+ /** p.380: "a team of grunts has a Group Edge equal to its Professional Rating." */
81
+ export function groupEdgeFor(professionalRating) {
82
+ return Math.max(0, Math.min(6, Math.round(professionalRating)));
83
+ }
84
+ /**
85
+ * p.379-380, verbatim in spirit: PR 0 "will flee if somebody in their
86
+ * group goes down"; PR 1-2 "retreat if more than a quarter of their
87
+ * team is taken out"; PR 3-4 "withdraw after casualties exceed half";
88
+ * PR 5-6 "will not break -- they fight to the last man".
89
+ */
90
+ export function moraleBandFor(professionalRating) {
91
+ if (professionalRating <= 0)
92
+ return 'one-drops';
93
+ if (professionalRating <= 2)
94
+ return 'quarter';
95
+ if (professionalRating <= 4)
96
+ return 'half';
97
+ return 'never';
98
+ }
99
+ /** Does a team of `total` that has lost `casualties` break at this PR? */
100
+ export function moraleBreaks(professionalRating, casualties, total) {
101
+ if (total <= 0 || casualties <= 0)
102
+ return false;
103
+ switch (moraleBandFor(professionalRating)) {
104
+ case 'one-drops': return casualties >= 1;
105
+ case 'quarter': return casualties > total / 4;
106
+ case 'half': return casualties > total / 2;
107
+ case 'never': return false;
108
+ }
109
+ }
110
+ /** The line the room hears when a team breaks, by how trained it was. */
111
+ export function moraleBreakLine(name, band) {
112
+ switch (band) {
113
+ case 'one-drops': return `${name} sees the body drop and wants no part of this -- hands up, backing off.`;
114
+ case 'quarter': return `${name} has seen enough of their own go down -- weapon lowered, backing out of it.`;
115
+ default: return `${name} calls it: too many down. Weapon down, hands where you can see them.`;
116
+ }
117
+ }
118
+ // ============================================================================
119
+ // Outfitting
120
+ // ============================================================================
121
+ /**
122
+ * WHAT AN NPC WEARS OR HOLDS, out of what a kit puts in its pack. The
123
+ * one list, shared by scene-factory's equip step and the ephemeral
124
+ * governor, so a guard built by a seed and a ganger spawned on a corner
125
+ * dress the same way. Weapons and armor only: a commlink stays in the
126
+ * pocket, and plot items (keys, chips) stay in inventory where "give"
127
+ * and the loot lane expect them.
128
+ */
129
+ export const NPC_EQUIP_CATEGORIES = new Set([
130
+ Category.Weapon, Category.MeleeWeapon, Category.RangedWeapon,
131
+ Category.Armor, Category.BodyArmor, Category.ArmorClothing, Category.Shield,
132
+ Category.Helmet, Category.ArmArmor, Category.LegArmor, Category.Boots, Category.Gloves,
133
+ ]);
134
+ const WEAPON_CATEGORIES = new Set([
135
+ Category.Weapon, Category.MeleeWeapon, Category.RangedWeapon,
136
+ ]);
137
+ /**
138
+ * Slots every weapon and armor piece in the pack into the actor's
139
+ * equipment, FIRST WEAPON WINS. `equip` swaps, so an unguarded loop
140
+ * over a pack holding a pistol and a knife ends with the knife in hand
141
+ * and the pistol holstered -- the exact bug archetypes.ts:outfitArchetype
142
+ * documents for runners, reproduced here for everyone else. Armor
143
+ * layers (a helmet over a jacket), so only weapons are guarded.
144
+ */
145
+ export function equipKitIntoHands(actor) {
146
+ for (const item of [...actor.inventory.getAllItems()]) {
147
+ if (!NPC_EQUIP_CATEGORIES.has(item.category))
148
+ continue;
149
+ if (WEAPON_CATEGORIES.has(item.category) && actor.getCarriedWeapon())
150
+ continue;
151
+ try {
152
+ actor.equip(item);
153
+ }
154
+ catch { /* nothing to slot it into -- stays holstered */ }
155
+ }
156
+ }
157
+ /**
158
+ * STAMP A KIT ONTO AN NPC. The NPC analogue of archetypes.ts
159
+ * outfitArchetype, minus the chargen ledger and the credstick.
160
+ *
161
+ * INTO THE PACK, NOT INTO THE HANDS. This adds gear to inventory and
162
+ * stops; equipping is the caller's step (scene-factory's Step 4.05 for
163
+ * a seeded NPC, equipKitIntoHands for an ephemeral), because the seeded
164
+ * path has an author to defer to: an item the seed put in this NPC's
165
+ * hands with `heldBy` is the author speaking, and the kit skips its
166
+ * own weapon rather than argue -- `respectCarried` below. Armor still
167
+ * layers.
168
+ *
169
+ * Idempotent on augs and qualities (hasAug / the Set), so a kit applied
170
+ * twice does not double-install wired reflexes.
171
+ */
172
+ export function outfitNpcKit(npc, kit, opts = {}) {
173
+ const logger = Logger.getInstance();
174
+ const lt = opts.lieutenant ? kit.lieutenant : undefined;
175
+ const attrs = { ...kit.attributes, ...(lt?.attributes ?? {}) };
176
+ const skills = { ...kit.skills, ...(lt?.skills ?? {}) };
177
+ const gear = lt?.gear ?? kit.gear;
178
+ const ammo = lt?.ammo ?? kit.ammo;
179
+ const augs = [...kit.augs, ...(lt?.augs ?? [])];
180
+ const qualities = [...kit.qualities, ...(lt?.qualities ?? [])];
181
+ const spells = [...kit.spells, ...(lt?.spells ?? [])];
182
+ const adeptPowers = { ...(kit.adeptPowers ?? {}), ...(lt?.adeptPowers ?? {}) };
183
+ npc.kit = kit.key;
184
+ npc.professionalRating = kit.professionalRating;
185
+ if (opts.lieutenant)
186
+ npc.lieutenant = true;
187
+ // The faction feature (factions.ts) ships rows and rules and no seed
188
+ // wiring; a kit row knows who fields it, so the gap closes here. An
189
+ // author's explicit faction still wins.
190
+ if (kit.faction && !npc.faction)
191
+ npc.faction = kit.faction;
192
+ npc.body = attrs.body;
193
+ npc.agility = attrs.agility;
194
+ npc.reaction = attrs.reaction;
195
+ npc.strength = attrs.strength;
196
+ npc.willpower = attrs.willpower;
197
+ npc.logic = attrs.logic;
198
+ npc.intuition = attrs.intuition;
199
+ npc.charisma = attrs.charisma;
200
+ if (attrs.magic !== undefined)
201
+ npc.magic = attrs.magic;
202
+ if (attrs.resonance !== undefined)
203
+ npc.resonance = attrs.resonance;
204
+ if (attrs.adept !== undefined)
205
+ npc.adept = attrs.adept;
206
+ // Grunts have no Edge POOL of their own (p.380): the team shares one
207
+ // equal to its PR, held on the CombatEncounter. The RATING stays on
208
+ // the actor because Player.consumeEdgeBoost adds `this.edge` dice
209
+ // when a boost lands -- p.56's "add Edge to the dice pool" -- and a
210
+ // grunt's Edge for that purpose is the group's. edgeRemaining is 0
211
+ // so nothing reads a per-actor pool.
212
+ npc.edge = groupEdgeFor(kit.professionalRating + (opts.lieutenant ? 1 : 0));
213
+ npc.edgeRemaining = 0;
214
+ // THE KIT IS THE STATLINE: replace, never merge. A `combat.skills`
215
+ // block a model wrote for a guard is superseded by the book's, and a
216
+ // merge would keep a dead key like `firearms` alive beside the live ones.
217
+ npc.skills = { ...skills };
218
+ const top = Object.entries(skills).sort((a, b) => b[1] - a[1])[0];
219
+ if (top)
220
+ npc.combatSkill = top[1];
221
+ for (const key of qualities) {
222
+ if (!npc.qualities.has(key))
223
+ npc.addQuality(key);
224
+ }
225
+ for (const key of augs) {
226
+ const aug = augFor(key);
227
+ if (aug && !npc.hasAug(key))
228
+ npc.installAug(aug);
229
+ else if (!aug)
230
+ logger.write(`npc-kit ${kit.key}: augmentation "${key}" has no row -- skipped.`);
231
+ }
232
+ if (spells.length > 0)
233
+ npc.knownSpells = [...new Set([...(npc.knownSpells ?? []), ...spells])];
234
+ if (Object.keys(adeptPowers).length > 0) {
235
+ npc.adept = true;
236
+ npc.adeptPowers = { ...(npc.adeptPowers ?? {}), ...adeptPowers };
237
+ }
238
+ const carriesWeapon = opts.respectCarried
239
+ && npc.inventory.getAllItems().some(i => WEAPON_CATEGORIES.has(i.category));
240
+ let firstGun;
241
+ for (const slug of gear) {
242
+ let item;
243
+ try {
244
+ item = itemFromCatalog(slug);
245
+ }
246
+ catch (err) {
247
+ // A missing gear row is a catalog problem, logged and survived:
248
+ // the guard is a little lighter, not absent. The kit suite walks
249
+ // every slug so this never reaches a player from a shipped row.
250
+ logger.write(`npc-kit ${kit.key}: ${err instanceof Error ? err.message : String(err)} -- skipped.`);
251
+ continue;
252
+ }
253
+ if (carriesWeapon && WEAPON_CATEGORIES.has(item.category))
254
+ continue;
255
+ try {
256
+ npc.addInventory(item);
257
+ }
258
+ catch (err) {
259
+ logger.write(`npc-kit ${kit.key}: could not give ${npc.name} ${item.name} (${err instanceof Error ? err.message : 'carry cap?'}).`);
260
+ continue;
261
+ }
262
+ item.owner = npc.name;
263
+ if (!firstGun && item.isFirearm())
264
+ firstGun = item;
265
+ }
266
+ // p.433-434: what is loaded shifts the gun's line -- this is how the
267
+ // PR 6 HK 227 gets its APDS AP -4 without the row lying about the gun.
268
+ if (ammo && firstGun) {
269
+ const round = itemFromCatalogQuiet(ammo);
270
+ if (round)
271
+ firstGun.loadedAmmo = round.name;
272
+ }
273
+ logger.write(`npc-kit: ${npc.name} outfitted as ${kit.key}${opts.lieutenant ? ' (lieutenant)' : ''} -- PR ${kit.professionalRating}, ${gear.length} piece(s).`);
274
+ }
275
+ function itemFromCatalogQuiet(slug) {
276
+ try {
277
+ return itemFromCatalog(slug);
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ /** Resolve-and-outfit in one call; a slug with no row is a no-op. Returns whether a kit landed. */
284
+ export function outfitNpcBySlug(npc, slug, opts = {}) {
285
+ if (!slug)
286
+ return false;
287
+ const kit = npcKitFor(slug);
288
+ if (!kit) {
289
+ Logger.getInstance().write(`npc-kit: no row for "${slug}" -- ${npc.name} keeps their attribute block.`);
290
+ return false;
291
+ }
292
+ outfitNpcKit(npc, kit, opts);
293
+ return true;
294
+ }
295
+ //# sourceMappingURL=npc-kits.js.map
@@ -952,6 +952,40 @@ export function factionFor(key) {
952
952
  export function catalogFactions() {
953
953
  return listOfKind('faction', factionFor);
954
954
  }
955
+ /**
956
+ * AN NPC KIT ROW, rebuilt as the engine reads it (catalog v21, "everyone
957
+ * is basically naked"). Same contract as factionFor: the rows live on
958
+ * maka-cli.com, an unknown slug answers undefined, and npc-kits.ts
959
+ * treats that as "no kit" -- a cached catalog from before v21 builds
960
+ * NPCs exactly as the engine always did.
961
+ */
962
+ export function npcKitFor(key) {
963
+ return kindIndex('npc-kit', row => {
964
+ const k = row.npcKit;
965
+ return {
966
+ key: row.slug,
967
+ name: row.name,
968
+ description: row.description,
969
+ professionalRating: k.professionalRating,
970
+ ...(k.faction !== undefined ? { faction: k.faction } : {}),
971
+ sort: k.sort,
972
+ attributes: { ...k.attributes },
973
+ skills: { ...k.skills },
974
+ gear: [...k.gear],
975
+ ...(k.ammo !== undefined ? { ammo: k.ammo } : {}),
976
+ augs: [...(k.augs ?? [])],
977
+ qualities: [...(k.qualities ?? [])],
978
+ spells: [...(k.spells ?? [])],
979
+ ...(k.adeptPowers !== undefined ? { adeptPowers: { ...k.adeptPowers } } : {}),
980
+ ...(k.lieutenant !== undefined ? { lieutenant: { ...k.lieutenant } } : {}),
981
+ page: row.page ?? '',
982
+ };
983
+ }).get(key);
984
+ }
985
+ /** Every NPC kit the resolved catalog knows. */
986
+ export function catalogNpcKits() {
987
+ return listOfKind('npc-kit', npcKitFor);
988
+ }
955
989
  /** Every metamagic and echo the resolved catalog knows. */
956
990
  export function catalogMetamagics() {
957
991
  return listOfKind('metamagic', metamagicFor);
@@ -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;
@@ -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
  }
@@ -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)
@@ -174,9 +174,17 @@ function postSweepOfficer(game, room, band) {
174
174
  // not follow.
175
175
  until: 'exchange',
176
176
  room,
177
- // A working rent-a-cop, not a boss fight. Deliberately ordinary:
178
- // the player who decides to swing at security should be picking a
179
- // fight with a person, not a set piece.
177
+ // PROFESSIONAL RATING 3, "Police Patrols" (SR5 p.383): the catalog's
178
+ // `police-patrol` npc-kit row -- armor jacket, Ares Predator V,
179
+ // Defiance EX Shocker, stun baton, Renraku Sensei, and a Biometric
180
+ // Reader (p.440), which is what the book has where this prose says
181
+ // "wand" -- SR5 prints no such item. factionOf() already read an
182
+ // 'authority' ephemeral as Knight Errant; the row says so too.
183
+ // A working beat cop, not a boss fight: the player who decides to
184
+ // swing at one is picking a fight with a person, not a set piece.
185
+ kit: SWEEP_OFFICER_KIT,
186
+ // A pre-v21 cache resolves to no kit and gets this instead: the old
187
+ // rent-a-cop numbers, exactly as before.
180
188
  combat: {
181
189
  body: 3, agility: 3, reaction: 3, strength: 3, willpower: 3,
182
190
  logic: 3, intuition: 3, charisma: 3, edge: 1, combatSkill: 3,
@@ -184,6 +192,8 @@ function postSweepOfficer(game, room, band) {
184
192
  },
185
193
  });
186
194
  }
195
+ /** The sweep officer's npc-kit row (npc-kits.ts). */
196
+ export const SWEEP_OFFICER_KIT = 'police-patrol';
187
197
  /**
188
198
  * THE OFFICERS, and there is more than one of them now.
189
199
  *
@@ -38,21 +38,32 @@ import { AI } from '../../../../tools/ai/ai.class.js';
38
38
  * the ephemeral never decides anything.
39
39
  */
40
40
  /**
41
- * PROFESSIONAL RATING 1 GANGER, canon stats (SR5 p.382, "Gangers &
42
- * Street Scum"): B/A/R/S 4, W/L/I/C 3, with Blades, Clubs,
43
- * Intimidation, Pistols and Unarmed Combat.
41
+ * PROFESSIONAL RATING 1 GANGER (SR5 p.382, "Gangers & Street Scum"):
42
+ * the catalog's `gangers` npc-kit row -- B/A 4, R 3, S 4, W/I/C 3, L 2,
43
+ * Blades 4 / Clubs 3 / Intimidation 4 / Pistols 4 / Unarmed 3,
44
+ * Toughness, an armor vest, a Browning Ultra-Power, a knife and a Meta
45
+ * Link (npc-kits.ts; the rows live on maka-cli.com, catalog v21).
44
46
  *
45
- * Copied rather than invented, and deliberately NOT scaled up. Canon's
46
- * own barrens encounter calls a gang ambush "a straightforward, quick,
47
- * and easy fight" for runners (Splintered State p.11). A mob that
48
- * threatened a professional crew would be a set piece, and a set piece
49
- * is a scene NPC's job, not a passing face's.
47
+ * THIS USED TO BE A CONSTANT HERE, and the constant was the bug. It
48
+ * said "canon stats" while giving them `firearms: 3` -- a skill that
49
+ * has not existed since stage 3 (skills.ts), written and never read --
50
+ * and no gun, so a "ganger with Pistols" always closed to punching
51
+ * range. The row carries the book's line, gear included, and a
52
+ * pre-v21 cache resolves to no kit, in which case the fallback block
53
+ * below is what they get: the old numbers, minus the dead key.
54
+ *
55
+ * Deliberately NOT scaled up. Canon's own barrens encounter calls a
56
+ * gang ambush "a straightforward, quick, and easy fight" for runners
57
+ * (Splintered State p.11). A mob that threatened a professional crew
58
+ * would be a set piece, and a set piece is a scene NPC's job, not a
59
+ * passing face's.
50
60
  */
51
- const GANGER_COMBAT = {
52
- body: 4, agility: 4, reaction: 4, strength: 4,
53
- willpower: 3, logic: 3, intuition: 3, charisma: 3,
61
+ export const GANGER_KIT = 'gangers';
62
+ const GANGER_FALLBACK_COMBAT = {
63
+ body: 4, agility: 4, reaction: 3, strength: 4,
64
+ willpower: 3, logic: 2, intuition: 3, charisma: 3,
54
65
  edge: 1, combatSkill: 3,
55
- skills: { blades: 3, clubs: 3, intimidation: 3, firearms: 3, unarmed: 3, perception: 2 },
66
+ skills: { blades: 4, clubs: 3, intimidation: 4, pistols: 4, unarmed: 3, perception: 2 },
56
67
  };
57
68
  /** How often a street corner turns out to be somebody's corner. */
58
69
  const GANG_CHANCE = 0.08;
@@ -116,7 +127,8 @@ export function maybeStreetGang(host, actor, room) {
116
127
  // with it through the doorway.
117
128
  until: 'fight',
118
129
  room,
119
- combat: GANGER_COMBAT,
130
+ kit: GANGER_KIT,
131
+ combat: GANGER_FALLBACK_COMBAT,
120
132
  });
121
133
  if (!npc)
122
134
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.201.0",
3
+ "version": "5.202.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",