@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +7 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +13 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +11 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +48 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/npc-factory.js +37 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +109 -11
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +41 -9
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +10 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +19 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +18 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +11 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +23 -3
- package/bundle/typescript/src/commands/game/sideQuest/npc-kits.js +295 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +87 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/commerce.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ephemeral.js +17 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +36 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +13 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/street-gang.js +25 -13
- 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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
*
|