@maka/maka-cli 5.201.0 → 5.203.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 (25) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +7 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +13 -2
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +11 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +48 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js +37 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +109 -11
  8. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +41 -9
  9. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +10 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/game.js +5 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +19 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +12 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +18 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +11 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +23 -3
  16. package/bundle/typescript/src/commands/game/sideQuest/npc-kits.js +295 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +34 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +87 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/commerce.js +5 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/ephemeral.js +17 -3
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +36 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +13 -3
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/street-gang.js +25 -13
  25. package/package.json +1 -1
@@ -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
  *