@maka/maka-cli 5.119.0 → 5.121.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 (82) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +2 -2
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +31 -2
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.d.ts +10 -3
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +17 -4
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.d.ts +0 -7
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js +39 -3
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +28 -4
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/fit.d.ts +29 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/fit.js +85 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/fit.js.map +1 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +4 -4
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +16 -5
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js +3 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.d.ts +34 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +81 -12
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +5 -2
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +29 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +4 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +7 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/game.js +12 -7
  37. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/models/device.d.ts +72 -0
  39. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +89 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/models/device.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +134 -45
  42. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +448 -104
  43. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +2 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +7 -5
  46. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +31 -3
  48. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +171 -47
  49. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/types/repro.d.ts +8 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +4 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +17 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js.map +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.d.ts +38 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +67 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js.map +1 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +279 -4
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +221 -212
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.d.ts +24 -5
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +50 -17
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js.map +1 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +6 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +28 -11
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +16 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js.map +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/first-aid.js +3 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/first-aid.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +6 -2
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.d.ts +19 -13
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js +77 -34
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/range.js.map +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.d.ts +2 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js +22 -3
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js.map +1 -1
  82. package/package.json +1 -1
@@ -1,8 +1,25 @@
1
1
  import { Size, Category, Rating } from '../types/shared/item-enum.js';
2
- import { PROGRAM_ATTR_MODS, findProgram, findSoft, getSoft } from '../utilities/programs.js';
3
- import { catalogHullFor } from '../utilities/catalog.js';
2
+ import { PROGRAM_ATTR_MODS, findProgram, findSoft, getSoft, getProgram } from '../utilities/programs.js';
3
+ import { catalogHullFor, catalogRowFor } from '../utilities/catalog.js';
4
+ import { guessRangeClass } from '../utilities/range.js';
4
5
  import { v4 as uuidv4 } from 'uuid';
5
6
  import { AbstractItem } from '../types/shared/abstracts.js';
7
+ /**
8
+ * What a running program adds to a Matrix attribute, from its catalog
9
+ * row's effects (v13). The program's key is the deck's word for it
10
+ * (utilities/programs.ts); the row is found by the program's name.
11
+ * PROGRAM_ATTR_MODS stands in when no row is loaded.
12
+ */
13
+ export function programMatrixMod(key, attr) {
14
+ const program = getProgram(key);
15
+ const row = program ? catalogRowFor(`${program.name} Program`) ?? catalogRowFor(program.name) : undefined;
16
+ if (row?.effects) {
17
+ return row.effects.reduce((n, e) => n + (e.category === 'matrix' && e.property === attr ? e.value : 0), 0);
18
+ }
19
+ if (attr === 'damage')
20
+ return key === 'hammer' ? 2 : 0;
21
+ return PROGRAM_ATTR_MODS[key]?.[attr] ?? 0;
22
+ }
6
23
  export class Item extends AbstractItem {
7
24
  _id;
8
25
  _owner;
@@ -191,26 +208,54 @@ export class Item extends AbstractItem {
191
208
  /**
192
209
  * STUN BY NATURE (SR5 p.170-171, p.424, RAG-checked 2026-09-02):
193
210
  * electricity damage is Stun, so a taser, a stun baton or shock gloves
194
- * never deal Physical.
211
+ * never deal Physical. THE ROW SAYS SO (catalog v13: weapon.damageType
212
+ * / weapon.electric); the name regex is the fallback for generated gear.
195
213
  */
196
214
  isStunWeapon() {
215
+ const w = this.row?.weapon;
216
+ if (w)
217
+ return w.damageType === 'S' || w.electric === true;
197
218
  return /taser|stun (baton|gun)|shock glove|pulsar|defiance ex|stunner/.test(this.weaponHay);
198
219
  }
199
220
  /**
200
- * STUN BY AMMUNITION (p.434): gel rounds deal Stun at AP +1; stick-n-shock
201
- * deal electric Stun at DV -2, AP -5. Anything else is Physical.
221
+ * WHAT THE LOADED ROUND DOES TO THE LINE (p.433-434): the ammunition
222
+ * row's block -- gel Stun at AP +1, stick-n-shock electric Stun at DV
223
+ * -2 / AP -5, APDS AP -4, explosive DV +1 / AP -1, flechette DV +2 /
224
+ * AP +5. Regular rounds shift nothing. Generated ammunition with no
225
+ * row keeps the two Stun names the engine always knew.
202
226
  */
203
- stunAmmo() {
204
- const a = (this.loadedAmmo ?? '').toLowerCase();
227
+ loadedAmmoMods() {
228
+ if (!this.loadedAmmo)
229
+ return undefined;
230
+ const row = catalogRowFor(this.loadedAmmo);
231
+ if (row?.ammo)
232
+ return { ...row.ammo, kind: row.slug };
233
+ const a = this.loadedAmmo.toLowerCase();
205
234
  if (/gel/.test(a))
206
- return { kind: 'gel', dvMod: 0, apMod: 1 };
235
+ return { kind: 'gel-rounds', dvMod: 0, apMod: 1, damageType: 'S' };
207
236
  if (/stick|shock/.test(a))
208
- return { kind: 'stick-n-shock', dvMod: -2, apMod: -5 };
237
+ return { kind: 'stick-n-shock', dvMod: -2, apMod: -5, damageType: 'S', electric: true };
209
238
  return undefined;
210
239
  }
240
+ /**
241
+ * STUN BY AMMUNITION (p.434): the loaded round's block when it deals
242
+ * Stun. Kept as the pre-v13 shape for its callers (combat-exchange,
243
+ * the non-lethal tests): gel or stick-n-shock, with the DV/AP shift.
244
+ */
245
+ stunAmmo() {
246
+ const mods = this.loadedAmmoMods();
247
+ if (!mods || mods.damageType !== 'S')
248
+ return undefined;
249
+ return { kind: mods.electric ? 'stick-n-shock' : 'gel', dvMod: mods.dvMod, apMod: mods.apMod };
250
+ }
211
251
  /** Ammunition that would make this gun non-lethal (reload picks it under the non-lethal stance). */
212
252
  static isStunAmmo(item) {
213
- return item.category === Category.Ammunition && /gel|stick|shock/i.test(item.name);
253
+ if (item.category !== Category.Ammunition)
254
+ return false;
255
+ const row = catalogRowFor(item.name);
256
+ if (row?.ammo)
257
+ return row.ammo.damageType === 'S';
258
+ return /gel|stick|shock/i.test(item.name);
214
259
  }
215
260
  get owner() {
216
261
  return this._owner;
@@ -224,8 +269,16 @@ export class Item extends AbstractItem {
224
269
  get transferable() {
225
270
  return this._transferable;
226
271
  }
272
+ /**
273
+ * "Nothing special happens" is honest for most props, but confusing for
274
+ * gear whose NAME implies a specific action that lives at a different
275
+ * verb -- a Lockpick Set at a pickable door (user, 2026-09-03). The
276
+ * row's `useHint` (catalog v13) names that verb; absent it, the generic
277
+ * line stands, which is correct for gear that really has nothing to say.
278
+ */
227
279
  use() {
228
- return `You use the ${this.name}. Nothing special happens.`;
280
+ const hint = this.row?.useHint;
281
+ return hint ? `You turn the ${this.name} over. ${hint}` : `You use the ${this.name}. Nothing special happens.`;
229
282
  }
230
283
  /**
231
284
  * How much this item's quality tier is worth in game mechanics: extra
@@ -244,45 +297,120 @@ export class Item extends AbstractItem {
244
297
  default: return 0;
245
298
  }
246
299
  }
247
- // ================= RAW weapon calibration (SR5e p.421-431) =================
248
- // Class-blind flat damage made a holdout hit like a shotgun. The class
249
- // is read off the item's name/description (the same duck-typing school
250
- // as isFence/droneArmed -- generated gear works unmodified), and the
251
- // numbers are the book's DV table compressed to engine scale: light
252
- // pistols under heavy pistols under shotguns/rifles, blades trading
253
- // reach for armor-piercing bite. Quality still rides on top -- gear
254
- // tier stays the progression system.
300
+ // ================= THE ROW BEHIND THE ITEM (catalog v13) =================
301
+ // An Item knows its name, category, size and price tier -- never its
302
+ // stat block. Since v13 the catalog row carries that block (weapon,
303
+ // wearable, device, vehicle, mod, ammo) and the row's effects, and
304
+ // every getter below reads the row FIRST. The fallbacks that follow
305
+ // are for generated street gear with no row, and they are the book's
306
+ // own class values (p.421-431, p.436-437) rather than the compressed
307
+ // scale the engine used to run: a generated heavy pistol and the
308
+ // catalog's Predator now hit on the same scale, which is the whole
309
+ // point of moving to canon numbers (project owner, 2026-09-03).
310
+ /** The catalog row, by exact slug or name (strict: catalogRowFor). */
311
+ get row() {
312
+ return catalogRowFor(this.name);
313
+ }
314
+ /** The SR5 Rating / Force / Capacity this thing carries: the row's, or
315
+ * the "(Rating N)" its name prints. Undefined for an unrated thing. */
316
+ get ratingValue() {
317
+ const fromRow = this.row?.ratingValue;
318
+ if (fromRow !== undefined)
319
+ return fromRow;
320
+ const m = /\((?:Rating|Force|Capacity|Level) (\d+)\)/i.exec(this.name);
321
+ return m ? Number(m[1]) : undefined;
322
+ }
255
323
  get weaponHay() {
256
324
  return `${this.name} ${this.description ?? ''}`.toLowerCase();
257
325
  }
258
- /** Base ranged DV before quality (RAW: holdout 6P ... assault 11P, compressed). */
259
- get rangedBase() {
260
- const hay = this.weaponHay;
261
- if (/hold-?out|derringer/.test(hay))
262
- return 2;
263
- if (/light\s?fire|light pistol|streetline|colt america|taser/.test(hay))
264
- return 3;
265
- if (/sniper|longarm|barret/.test(hay))
266
- return 6;
267
- if (/assault|ak-?\d|alpha|\brifle\b|carbine/.test(hay))
268
- return 5;
269
- if (/shotgun|roomsweeper|defiance|scattergun/.test(hay))
270
- return 5;
271
- if (/smg|machine pistol|submachine|uzi|ingram/.test(hay))
272
- return 4;
273
- // Heavy pistols (Predator, Ultra-Power...) and unrecognized iron.
274
- return 4;
326
+ /** The p.185 range class: the row's, else read off the name (utilities/range.ts guessRangeClass). */
327
+ get rangeClass() {
328
+ return this.row?.weapon?.range ?? guessRangeClass(this.weaponHay, this.category === Category.MeleeWeapon);
275
329
  }
276
- /** Base melee DV before STR/quality (RAW: knife STR+2, sword/katana/club STR+3). */
277
- get meleeBase() {
278
- const hay = this.weaponHay;
279
- if (/knife|dagger|stiletto|shiv|razor/.test(hay))
280
- return 2;
281
- if (/katana|sword|axe|machete|claymore|blade/.test(hay))
282
- return 3;
283
- if (/club|bat\b|staff|pipe|crowbar|hammer|maul|mace/.test(hay))
284
- return 3;
285
- return 2;
330
+ /** The skill this weapon is used with: the row's, else the class's. */
331
+ get weaponSkill() {
332
+ const fromRow = this.row?.weapon?.skill;
333
+ if (fromRow)
334
+ return fromRow;
335
+ if (this.category === Category.MeleeWeapon) {
336
+ return /club|\bbat\b|baton|staff|hammer|\bpipe\b|wrench|\bsap\b|\bmace\b|crowbar|cudgel/i.test(`${this.name} ${this.shape}`) ? 'clubs' : 'blades';
337
+ }
338
+ switch (this.rangeClass) {
339
+ case 'machine-pistol':
340
+ case 'smg':
341
+ case 'assault-rifle': return 'automatics';
342
+ case 'shotgun':
343
+ case 'sniper': return 'longarms';
344
+ case 'lmg':
345
+ case 'mmg': return 'heavy-weapons';
346
+ case 'bow':
347
+ case 'light-crossbow':
348
+ case 'medium-crossbow':
349
+ case 'heavy-crossbow': return 'archery';
350
+ case 'thrown-knife':
351
+ case 'grenade': return 'throwing-weapons';
352
+ default: return 'pistols';
353
+ }
354
+ }
355
+ /**
356
+ * THE DV, the book's flat part (p.421-431): 8 for the Predator's "8P",
357
+ * 2 for a knife's "(STR+2)P" -- the Strength half is the wielder's and
358
+ * Player.getWeaponProfile adds it (dvAttribute says whether to). The
359
+ * fallback is the CLASS row of the same tables, un-compressed.
360
+ */
361
+ get weaponDv() {
362
+ const w = this.row?.weapon;
363
+ if (w)
364
+ return w.dv;
365
+ if (this.category === Category.MeleeWeapon) {
366
+ if (this.isStunWeapon())
367
+ return 8; // shock gloves / stun baton class
368
+ const hay = this.weaponHay;
369
+ if (/knife|dagger|stiletto|shiv|razor|sap|baton/.test(hay))
370
+ return 2; // (STR+2)
371
+ return 3; // sword, katana, club, staff: (STR+3)
372
+ }
373
+ switch (this.rangeClass) {
374
+ case 'taser': return 7;
375
+ case 'holdout': return 6;
376
+ case 'light-pistol': return 6;
377
+ case 'machine-pistol': return 6;
378
+ case 'smg': return 7;
379
+ case 'shotgun': return 10;
380
+ case 'assault-rifle': return 10;
381
+ case 'sniper': return 13;
382
+ case 'lmg': return 9;
383
+ case 'mmg': return 10;
384
+ case 'medium-crossbow':
385
+ case 'light-crossbow':
386
+ case 'heavy-crossbow': return 7;
387
+ case 'grenade': return 16;
388
+ case 'thrown-knife': return 1;
389
+ default: return 8; // heavy pistols and unrecognised iron
390
+ }
391
+ }
392
+ /** Which attribute the DV rides on: melee is (STR + dv) unless the row says otherwise. */
393
+ get dvAttribute() {
394
+ const w = this.row?.weapon;
395
+ if (w)
396
+ return w.dvAttribute;
397
+ if (this.category === Category.MeleeWeapon && !this.isStunWeapon())
398
+ return 'strength';
399
+ if (this.rangeClass === 'thrown-knife')
400
+ return 'strength';
401
+ return undefined;
402
+ }
403
+ /** Physical or Stun, before ammunition and stance have their say. */
404
+ get damageType() {
405
+ return this.row?.weapon?.damageType ?? (this.isStunWeapon() ? 'S' : 'P');
406
+ }
407
+ /** @deprecated v13 -- the DV is weaponDv; kept one release for its readers. */
408
+ get rangedBase() { return this.category === Category.MeleeWeapon ? 0 : this.weaponDv; }
409
+ /** @deprecated v13 -- the DV is weaponDv; kept one release for its readers. */
410
+ get meleeBase() { return this.category === Category.MeleeWeapon ? this.weaponDv : 0; }
411
+ /** Reach (p.421-423): the row's, else none. */
412
+ get reach() {
413
+ return this.row?.weapon?.reach ?? 0;
286
414
  }
287
415
  /**
288
416
  * ACCURACY -- the LIMIT on an attack made with this weapon (p.46-47,
@@ -291,41 +419,25 @@ export class Item extends AbstractItem {
291
419
  * inherent one, higher or lower (p.46-47), so this is not a floor or a
292
420
  * cap on the Physical limit -- it is the limit.
293
421
  *
294
- * NOT COMPRESSED, unlike the DV values above. Damage was squeezed to
295
- * engine scale because it is measured against soak dice this engine
296
- * also scales; a limit is measured against HITS, which are the book's
297
- * own units, and the Matrix brackets next door already spend the raw
298
- * attribute ("whole, never halved"). Compressing here would make a
299
- * sniper rifle and a holdout cap at the same place.
300
- *
301
- * The numbers are the book's tables (p.421-431), read through the same
302
- * duck-typing school as rangedBase/armorPen so generated gear works
303
- * unmodified. Class-based rather than per-weapon ON PURPOSE: the RAG
304
- * could not produce standard-table rows for every gun this game ships
305
- * (the shotgun table did not come back, and the one AK-97 figure it
306
- * did return was off a Run Faster character sheet -- a MODIFIED
307
- * weapon, not the table), so a per-weapon list would have meant
308
- * inventing the gaps. Every value below is one the book gave up.
422
+ * THIS IS THE BARE NUMBER, NOT THE PARENTHETICAL. Tables write "Acc 5
423
+ * (7)": the 5 is the gun, and the 7 is the gun PLUS a shooter who owns
424
+ * a smartlink (p.433, +2 Accuracy with a smartgun system) -- see
425
+ * Player.smartlinkAccuracyBonus, which supplies the +2 in
426
+ * getAttackLimit. A laser sight's +1 rides the same way (fitted mods,
427
+ * Player.gearLimit), and never stacks with the smartlink (p.432).
309
428
  *
310
- * THIS IS THE BARE NUMBER, NOT THE PARENTHETICAL, and that is now a
311
- * division of labour rather than a gap. Tables write "Acc 5 (7)": the
312
- * 5 is the gun, and the 7 is the gun PLUS a shooter who owns a
313
- * smartlink (p.433, +2 Accuracy with a smartgun system). A gun sitting
314
- * on a table has no idea who will pick it up, so the pairing is asked
315
- * of the shooter -- see Player.smartlinkAccuracyBonus, which supplies
316
- * the +2 in getAttackLimit. fxPHSuE4nrH3gAGiy.
429
+ * 0 means "no Accuracy of its own": the table's own word 'physical'
430
+ * (shock gloves, throwing knives, grenades) and everything that is not
431
+ * a weapon. Callers fall back to the Physical limit, which is what an
432
+ * improvised or bare-handed attack uses (p.168).
317
433
  *
318
- * The laser-sight parenthetical (p.429 Enfield, "4 (5) with the
319
- * built-in laser sight") is a DIFFERENT accessory and is still not
320
- * modelled: the engine has no sight-fitting model, and inventing one
321
- * to reuse the smartlink pairing would be guessing at a second rule.
322
- *
323
- * Note the wireless smartgun bonus is a DICE POOL bonus (+1/+2,
324
- * p.433), NOT an Accuracy bonus -- getAttackPool already spends it as
325
- * dice. Keeping those apart is the same discipline as the Matrix
326
- * brackets: a limit is never a die, and a die is never a limit.
434
+ * The fallback ladder is the book's class values, read through the
435
+ * same duck-typing school as the DV so generated gear works.
327
436
  */
328
437
  get accuracy() {
438
+ const w = this.row?.weapon;
439
+ if (w)
440
+ return typeof w.accuracy === 'number' ? w.accuracy : 0;
329
441
  const hay = this.weaponHay;
330
442
  if (this.category === Category.MeleeWeapon) {
331
443
  if (/katana/.test(hay))
@@ -338,7 +450,9 @@ export class Item extends AbstractItem {
338
450
  return 5;
339
451
  if (/sap|whip|garrote/.test(hay))
340
452
  return 5;
341
- if (/club|bat\b|baton|staff|pipe|crowbar|hammer|maul|mace|cudgel/.test(hay))
453
+ if (/staff/.test(hay))
454
+ return 6; // p.423
455
+ if (/club|bat\b|baton|pipe|crowbar|hammer|maul|mace|cudgel/.test(hay))
342
456
  return 4;
343
457
  return 5;
344
458
  }
@@ -364,43 +478,263 @@ export class Item extends AbstractItem {
364
478
  // Heavy pistols (Predator, Ultra-Power) and unrecognized iron.
365
479
  return 5;
366
480
  }
367
- // Not a weapon: no Accuracy of its own. Callers fall back to the
368
- // Physical limit, which is what an improvised or bare-handed
369
- // attack uses (p.168).
370
481
  return 0;
371
482
  }
372
483
  /**
373
- * Armor penetration (<= 0): how much of the defender's ARMOR the hit
374
- * ignores (RAW AP, compressed ~2:3 -- knife/katana -3 -> -2, heavy
375
- * pistol -1 -> -1). Applied against armor dice only, never Body
376
- * (combat-exchange.ts).
484
+ * Armor penetration: how much the hit shifts the defender's ARMOR
485
+ * (p.169) -- negative eats it, a "+" (gel, flechette) adds to it. The
486
+ * row's AP, else the class value from the same tables, un-compressed.
377
487
  */
378
488
  get armorPen() {
489
+ const w = this.row?.weapon;
490
+ if (w)
491
+ return w.ap;
379
492
  const hay = this.weaponHay;
380
493
  if (this.category === Category.MeleeWeapon) {
381
494
  if (/knife|dagger|stiletto|katana/.test(hay))
495
+ return -3;
496
+ if (/sword|axe|machete|claymore|blade|pole\s?arm/.test(hay))
382
497
  return -2;
383
- if (/sword|axe|machete|claymore|blade/.test(hay))
384
- return -1;
385
- if (/club|bat\b|staff|pipe|crowbar|hammer|maul|mace|sap/.test(hay))
498
+ if (/taser|stun|shock/.test(hay))
499
+ return -5;
500
+ if (/club|bat\b|staff|pipe|crowbar|hammer|maul|mace|sap|baton/.test(hay))
386
501
  return 0;
387
502
  return -1;
388
503
  }
389
504
  if (this.category === Category.RangedWeapon || this.category === Category.Weapon) {
390
- if (/sniper|longarm|barret/.test(hay))
391
- return -2;
392
- if (/assault|ak-?\d|alpha|\brifle\b|carbine|shotgun|roomsweeper|defiance/.test(hay))
393
- return -1;
394
- if (/hold-?out|derringer|light\s?fire|light pistol|streetline|colt america|taser|smg|machine pistol|submachine|uzi|ingram/.test(hay))
395
- return 0;
396
- return -1; // heavy pistols (RAW -1)
505
+ switch (this.rangeClass) {
506
+ case 'taser': return -5;
507
+ case 'sniper': return -4;
508
+ case 'assault-rifle': return -2;
509
+ case 'mmg': return -3;
510
+ case 'lmg': return -2;
511
+ case 'shotgun': return -1;
512
+ case 'holdout':
513
+ case 'light-pistol':
514
+ case 'machine-pistol':
515
+ case 'smg': return 0;
516
+ case 'medium-crossbow':
517
+ case 'light-crossbow':
518
+ case 'heavy-crossbow': return -2;
519
+ default: return -1; // heavy pistols (RAW -1)
520
+ }
397
521
  }
398
522
  return 0;
399
523
  }
400
- /** Heavier armor classes soak harder (RAW: jacket 12 vs clothing 6). */
524
+ /** Heavier armor classes soak harder (RAW: jacket 12 vs clothing 6). Fallback only. */
401
525
  get isHeavyArmor() {
402
526
  return /jacket|full body|milspec|mil-spec|plate|riot|ballistic|hardened/.test(this.weaponHay);
403
527
  }
528
+ /**
529
+ * THE ARMOR RATING of a worn piece (p.436-437): the row's, else the
530
+ * class value -- jacket-class 12, a vest 9, armor clothing 6, plain
531
+ * clothing 0. Only the highest worn piece counts (Player.armorBreakdown).
532
+ */
533
+ get armorValue() {
534
+ const w = this.row?.wearable;
535
+ if (w?.armor !== undefined)
536
+ return w.armor;
537
+ if (w?.armorBonus !== undefined)
538
+ return 0;
539
+ switch (this.category) {
540
+ case Category.Armor:
541
+ case Category.BodyArmor:
542
+ return this.isHeavyArmor ? 12 : 9;
543
+ case Category.ArmorClothing:
544
+ return this.isHeavyArmor ? 9 : 6;
545
+ default:
546
+ return 0;
547
+ }
548
+ }
549
+ /** A "+" accessory's bonus (helmet +2, shield +6, p.437-438): the row's, else the class value. */
550
+ get armorBonus() {
551
+ const w = this.row?.wearable;
552
+ if (w?.armorBonus !== undefined)
553
+ return w.armorBonus;
554
+ if (w?.armor !== undefined)
555
+ return 0;
556
+ if (this.category === Category.Helmet)
557
+ return 2;
558
+ if (this.category === Category.Shield)
559
+ return 6;
560
+ return 0;
561
+ }
562
+ // ---- Effects and capabilities (catalog v13) ----
563
+ /**
564
+ * WHAT THIS THING DOES, in the engine's vocabulary: the row's effects
565
+ * plus everything fitted into it (a smartlink in the goggles, a laser
566
+ * sight on the gun). A fitted mod's effects apply whenever the host
567
+ * does, so they are re-tagged with the host's own `when`.
568
+ */
569
+ get effects() {
570
+ const own = this.row?.effects ?? [];
571
+ if (this.fitted.length === 0)
572
+ return own;
573
+ const hostWhen = this.defaultWhen;
574
+ const fromMods = this.fitted.flatMap(m => m.effects.map(e => e.when === 'fitted' ? { ...e, when: hostWhen } : e));
575
+ return [...own, ...fromMods];
576
+ }
577
+ /** When a row's effects apply by default: worn for a wearable, held for a weapon, carried otherwise. */
578
+ get defaultWhen() {
579
+ if (this.row?.weapon || this.category === Category.MeleeWeapon || this.isFirearm())
580
+ return 'held';
581
+ if (this.row?.wearable)
582
+ return 'worn';
583
+ return 'carried';
584
+ }
585
+ /** A flag the row (or a fitted mod) grants, by key; its value, or 0. */
586
+ capability(key) {
587
+ let total = 0;
588
+ for (const e of this.effects)
589
+ if (e.type === 'flag' && e.property === key)
590
+ total += e.value;
591
+ return total;
592
+ }
593
+ hasCapability(key) {
594
+ return this.capability(key) > 0;
595
+ }
596
+ // ---- Fitting (catalog v13; commands/fit.ts) ----
597
+ // Wearables hold mods by CAPACITY (goggles 4, helmet 6; a vision
598
+ // enhancement costs 1, p.443-445) and guns hold accessories by MOUNT
599
+ // (top / under / barrel, one accessory each, p.432). Built-in
600
+ // accessories occupy no mount. The fitted list rides the save.
601
+ fitted = [];
602
+ /** Capacity this thing offers to mods. */
603
+ get capacity() {
604
+ return this.row?.wearable?.capacity ?? this.row?.mod?.capacity ?? 0;
605
+ }
606
+ /** What kinds of mod it holds. */
607
+ get holds() {
608
+ const w = this.row?.wearable?.holds;
609
+ if (w)
610
+ return w;
611
+ return this.row?.mod?.capacity ? ['vision'] : [];
612
+ }
613
+ get capacityUsed() {
614
+ return this.fitted.reduce((n, m) => n + (m.row?.mod?.capacityCost ?? 0), 0);
615
+ }
616
+ /** The accessory seats a gun has: the row's, else the class rule (p.432). */
617
+ get mounts() {
618
+ const fromRow = this.row?.weapon?.mounts;
619
+ if (fromRow)
620
+ return fromRow;
621
+ if (!this.isFirearm())
622
+ return [];
623
+ switch (this.rangeClass) {
624
+ case 'holdout': return [];
625
+ case 'taser': return ['top'];
626
+ case 'light-pistol':
627
+ case 'heavy-pistol':
628
+ case 'machine-pistol':
629
+ case 'smg': return ['top', 'barrel'];
630
+ default: return ['top', 'under', 'barrel'];
631
+ }
632
+ }
633
+ /** Accessories the factory fitted (identifiers, see ICatalogWeapon.builtIn). */
634
+ get builtIn() {
635
+ return this.row?.weapon?.builtIn ?? [];
636
+ }
637
+ hasBuiltIn(id) {
638
+ return this.builtIn.includes(id);
639
+ }
640
+ /**
641
+ * A SMARTGUN (p.433): a smartgun system built in or fitted. Before v13
642
+ * every firearm in the engine counted as one; canon makes it an
643
+ * accessory. Generated gear with no row is a smartgun only when it
644
+ * says so.
645
+ */
646
+ get isSmartgun() {
647
+ if (!this.isFirearm())
648
+ return false;
649
+ if (this.row)
650
+ return this.hasBuiltIn('smartgun-internal') || this.fitted.some(m => m.hasCapability('smartgun'));
651
+ return /smartgun|smartlink/.test(this.weaponHay) || this.fitted.some(m => m.hasCapability('smartgun'));
652
+ }
653
+ /** Which mount a fitted accessory sits in: the first of its mounts still free when it was fitted. */
654
+ mountOf(mod) {
655
+ const taken = new Set();
656
+ for (const m of this.fitted) {
657
+ const wants = m.row?.mod?.mount ?? [];
658
+ const seat = wants.find(p => this.mounts.includes(p) && !taken.has(p));
659
+ if (seat)
660
+ taken.add(seat);
661
+ if (m === mod)
662
+ return seat;
663
+ }
664
+ return undefined;
665
+ }
666
+ /**
667
+ * Why a mod will not fit, or null when it will. The message is the
668
+ * player's line, so it names the rule that refused.
669
+ */
670
+ fitRefusal(mod) {
671
+ const spec = mod.row?.mod;
672
+ if (!spec)
673
+ return `${mod.name} is not something that fits into anything.`;
674
+ if (this.fitted.includes(mod))
675
+ return `${mod.name} is already fitted to ${this.name}.`;
676
+ if (spec.kind === 'weapon') {
677
+ if (!this.isFirearm())
678
+ return `${mod.name} mounts on a gun, and ${this.name} is not one.`;
679
+ const mounts = this.mounts;
680
+ if (mounts.length === 0)
681
+ return `${this.name} has no mounts -- nothing bolts onto it (p.432).`;
682
+ if (spec.excludes?.includes('shotgun') && this.rangeClass === 'shotgun')
683
+ return `${mod.name} will not fit a shotgun (p.432).`;
684
+ if (spec.excludes?.includes('revolver') && this.row?.weapon?.ammo?.feed === 'cy')
685
+ return `${mod.name} will not fit a revolver (p.432).`;
686
+ const taken = new Set(this.fitted.map(m => this.mountOf(m)).filter((p) => p !== undefined));
687
+ const free = (spec.mount ?? []).filter(p => mounts.includes(p) && !taken.has(p));
688
+ if (free.length === 0)
689
+ return `${this.name} has no free ${(spec.mount ?? []).join(' or ')} mount for ${mod.name}.`;
690
+ return null;
691
+ }
692
+ if (!this.holds.includes(spec.kind))
693
+ return `${this.name} does not take a ${spec.kind} enhancement.`;
694
+ const cost = spec.capacityCost ?? 0;
695
+ if (this.capacityUsed + cost > this.capacity)
696
+ return `${this.name} has ${this.capacity - this.capacityUsed} capacity left and ${mod.name} needs ${cost}.`;
697
+ return null;
698
+ }
699
+ /** Fits a mod; the refusal line, or null when it went in. */
700
+ fit(mod) {
701
+ const why = this.fitRefusal(mod);
702
+ if (why)
703
+ return why;
704
+ this.fitted.push(mod);
705
+ return null;
706
+ }
707
+ /** Takes a mod back out; false when it was not there. */
708
+ unfit(mod) {
709
+ const i = this.fitted.indexOf(mod);
710
+ if (i < 0)
711
+ return false;
712
+ this.fitted.splice(i, 1);
713
+ return true;
714
+ }
715
+ /** The drone's stat block, when it has one. */
716
+ get vehicle() {
717
+ return this.row?.vehicle;
718
+ }
719
+ /** Pilot rating (p.199): the row's, else the price-tier fallback the engine always used. */
720
+ get pilotRating() {
721
+ return this.vehicle?.pilot ?? 2 + this.qualityBonus;
722
+ }
723
+ /** A drone's hull armor (p.199): the row's, else the tier fallback. */
724
+ get droneArmor() {
725
+ return this.vehicle?.armor ?? 2 * this.qualityBonus;
726
+ }
727
+ /** A drone's Body (p.199): the row's, else the tier fallback. */
728
+ get droneBody() {
729
+ return this.vehicle?.body ?? 2 + this.qualityBonus;
730
+ }
731
+ /** Does the drone carry a weapon mount: the row's, else the name says. */
732
+ get hasWeaponMount() {
733
+ const v = this.vehicle;
734
+ if (v)
735
+ return v.mount !== undefined;
736
+ return /armed|combat|gun|roto|assault|sentry|weapon/i.test(this.weaponHay);
737
+ }
404
738
  // ================= Device condition monitor (cyberdecks) =================
405
739
  // Matrix combat damage lands on HARDWARE: the deck you jacked in with
406
740
  // (see combat-exchange.ts routing and Player.takeMatrixDamage). A full
@@ -411,9 +745,9 @@ export class Item extends AbstractItem {
411
745
  isCyberdeck() {
412
746
  return this.category === Category.Cyberdeck;
413
747
  }
414
- /** Better decks have more board to burn through: 8 + 2 per quality step. */
748
+ /** The Matrix condition monitor (p.228): 8 + (Device Rating / 2), rounded up. */
415
749
  get maxDeckBoxes() {
416
- return 8 + 2 * this.qualityBonus;
750
+ return 8 + Math.ceil(this.deviceRating / 2);
417
751
  }
418
752
  get deckDamage() {
419
753
  return this._deckDamage;
@@ -425,8 +759,12 @@ export class Item extends AbstractItem {
425
759
  // engine's quality tiers map onto the book's table exactly (Summit
426
760
  // DR1 = Common ... Cyber-5 DR5 = Legendary), so generated street
427
761
  // decks inherit real stats for free.
428
- /** p.227: Summit 1, Chariot 2, Tsurugi 3, Cyber-5 5 (Excalibur-class 6). */
762
+ /** Device Rating, NUMERIC (catalog v13): the row's, else the tier ladder
763
+ * (p.227: Summit 1, Chariot 2, Tsurugi 3, Cyber-5 5, Excalibur-class 6). */
429
764
  get deviceRating() {
765
+ const fromRow = this.row?.device?.deviceRating;
766
+ if (fromRow !== undefined)
767
+ return fromRow;
430
768
  switch (this.qualityBonus) {
431
769
  case 0: return 1;
432
770
  case 1: return 2;
@@ -435,8 +773,12 @@ export class Item extends AbstractItem {
435
773
  default: return 6;
436
774
  }
437
775
  }
438
- /** The p.227 attribute array (highest first), by Device Rating. */
776
+ /** The p.227 attribute array (highest first): the row's four numbers, else by Device Rating. */
439
777
  get deckArray() {
778
+ const d = this.row?.device;
779
+ if (d && d.attack !== undefined && d.sleaze !== undefined && d.dataProcessing !== undefined && d.firewall !== undefined) {
780
+ return [d.attack, d.sleaze, d.dataProcessing, d.firewall];
781
+ }
440
782
  switch (this.deviceRating) {
441
783
  case 1: return [4, 3, 3, 1];
442
784
  case 2: return [5, 4, 4, 2];
@@ -445,9 +787,9 @@ export class Item extends AbstractItem {
445
787
  default: return [9, 8, 7, 6]; // Excalibur-class (canon-approximate)
446
788
  }
447
789
  }
448
- /** How many programs run at once (p.227; storage is unlimited). */
790
+ /** How many programs run at once (p.227; storage is unlimited): the row's, else the Device Rating. */
449
791
  get programSlots() {
450
- return Math.min(6, this.deviceRating);
792
+ return this.row?.device?.programs ?? Math.min(6, this.deviceRating);
451
793
  }
452
794
  // Which array value sits in which attribute: indexes into deckArray
453
795
  // for [attack, sleaze, dataProcessing, firewall]. Default boot config
@@ -520,7 +862,9 @@ export class Item extends AbstractItem {
520
862
  }
521
863
  attrValue(slot, key) {
522
864
  const base = this.deckArray[this._deckOrder[slot]];
523
- const mods = this.loadedPrograms.reduce((sum, k) => sum + (PROGRAM_ATTR_MODS[k]?.[key] ?? 0), 0);
865
+ // What a RUNNING program adds (catalog v13: the program row's effects,
866
+ // `attribute`/`matrix`; PROGRAM_ATTR_MODS is the offline fallback).
867
+ const mods = this.loadedPrograms.reduce((sum, k) => sum + programMatrixMod(k, key), 0);
524
868
  return base + mods;
525
869
  }
526
870
  get attack() { return this.attrValue(0, 'attack'); }
@@ -573,7 +917,7 @@ export class Item extends AbstractItem {
573
917
  get matrixDamageBonus() {
574
918
  if (!this.isCyberdeck())
575
919
  return this.qualityBonus;
576
- return Math.floor(this.attack / 2) + (this.hasProgram('hammer') ? 2 : 0);
920
+ return Math.floor(this.attack / 2) + this.loadedPrograms.reduce((sum, k) => sum + programMatrixMod(k, 'damage'), 0);
577
921
  }
578
922
  /** One-line stat readout: "A5 S4 D4 F2, DR2, 1/2 programs". */
579
923
  deckStatLine() {