@maka/maka-cli 5.118.0 → 5.119.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 (104) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +9 -2
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.d.ts +14 -2
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js +73 -38
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js.map +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js.map +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +17 -7
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.d.ts +10 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +72 -20
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +16 -3
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +14 -2
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +4 -4
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +6 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +12 -2
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js +7 -6
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js.map +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +6 -3
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js.map +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +1 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +2 -2
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/sin.js +11 -3
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/sin.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +5 -3
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +1 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.d.ts +6 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js +17 -4
  43. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +18 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/game.js +92 -16
  46. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +7 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/models/host.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +50 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +78 -2
  51. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +88 -2
  53. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +302 -50
  54. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/qualities.d.ts +34 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/qualities.js +58 -26
  57. package/bundle/typescript/src/commands/game/sideQuest/qualities.js.map +1 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/types/player-equipment.d.ts +8 -12
  59. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +12 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.d.ts +22 -16
  61. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.js +22 -16
  62. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/ui.js +2 -2
  64. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +13 -8
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +8 -7
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +31 -6
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.d.ts +23 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +48 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.d.ts +5 -1
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +5 -27
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js.map +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +2 -2
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js.map +1 -1
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.d.ts +25 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.js +10 -0
  82. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.js.map +1 -0
  83. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.d.ts +29 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.js +64 -0
  85. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.js.map +1 -0
  86. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +8 -0
  87. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js.map +1 -1
  88. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.d.ts +42 -2
  89. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +75 -4
  90. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js.map +1 -1
  91. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +3 -3
  92. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js.map +1 -1
  93. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.d.ts +1 -3
  94. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +85 -7
  95. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  96. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +16 -5
  97. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  98. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.d.ts +16 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +12 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js.map +1 -1
  101. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.d.ts +2 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +23 -4
  103. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js.map +1 -1
  104. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { getQuality, resolveQualityKey } from '../qualities.js';
1
2
  import { isAspectKey } from '../aspects.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { Category } from '../types/shared/item-enum.js';
@@ -11,7 +12,13 @@ import { physicalLimit } from '../utilities/grapple.js';
11
12
  import { astralLimit } from '../utilities/limits.js';
12
13
  import { Inventory } from './inventory.js';
13
14
  import { AbstractPlayer } from '../types/shared/abstracts.js';
14
- export const STANCE_CYCLE = ['manual', 'guarded', 'aggressive'];
15
+ export const STANCE_CYCLE = ['manual', 'non-lethal', 'lethal'];
16
+ /** The old names, still accepted from saves and the stance command. */
17
+ export const STANCE_ALIASES = { guarded: 'non-lethal', aggressive: 'lethal', nonlethal: 'non-lethal', 'non lethal': 'non-lethal' };
18
+ export function normalizeStance(v) {
19
+ const k = (v ?? 'manual').toLowerCase();
20
+ return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : 'manual');
21
+ }
15
22
  /** Meat and a jumped-in drone share the physical world. */
16
23
  export function isPhysicalPlane(plane) {
17
24
  return plane === 'meat' || plane === 'drone';
@@ -576,7 +583,29 @@ export class Player extends AbstractPlayer {
576
583
  return true;
577
584
  if (this.hasAug('datajack'))
578
585
  return true;
579
- return this._inventory.getAllItems().some(i => i.category === Category.Electronics && /\btrodes?\b/i.test(i.name));
586
+ return this.dniSource() !== null;
587
+ }
588
+ /** True for the one Electronics item that is a trode net (p.439-440). */
589
+ static isTrodeNet(item) {
590
+ return item.category === Category.Electronics && /\btrodes?\b/i.test(item.name);
591
+ }
592
+ /**
593
+ * WHAT CARRIES THE DIRECT NEURAL INTERFACE, for the equipment panel and
594
+ * the jack-in gate: a living persona, an implanted datajack, a trode net
595
+ * worn on the head, or one still in the bag (it works from there too --
596
+ * the engine has never made the player put it on, and a net in the bag
597
+ * was the only place it could live before the head slot existed).
598
+ */
599
+ dniSource() {
600
+ if (this.isTechnomancer())
601
+ return { kind: 'technomancer' };
602
+ if (this.hasAug('datajack'))
603
+ return { kind: 'datajack' };
604
+ const worn = this._equipment.head?.trodes;
605
+ if (worn)
606
+ return { kind: 'trodes', worn: true, item: worn };
607
+ const bagged = this._inventory.getAllItems().find(i => Player.isTrodeNet(i));
608
+ return bagged ? { kind: 'trodes', worn: false, item: bagged } : null;
580
609
  }
581
610
  /**
582
611
  * THE PRICE OF SILENCE (canon p.235-236 "Running Silent",
@@ -1189,7 +1218,23 @@ export class Player extends AbstractPlayer {
1189
1218
  * the only quality that edits an attribute (RAW raises the Edge ceiling;
1190
1219
  * with no attribute point-buy here, the point lands directly).
1191
1220
  */
1192
- addQuality(key) {
1221
+ /** Rating per rated quality (qualities.ts maxRating); absent = 1. */
1222
+ qualityRatings = new Map();
1223
+ /** The rating held, 0 when the quality is not held; a flat quality reads 1. */
1224
+ qualityRating(key) {
1225
+ if (!this.qualities.has(key))
1226
+ return 0;
1227
+ return this.qualityRatings.get(key) ?? 1;
1228
+ }
1229
+ addQuality(rawKey, rating) {
1230
+ // A legacy "<key>-1" resolves to the rated key at 1 (qualities.ts).
1231
+ const resolved = resolveQualityKey(rawKey);
1232
+ const key = resolved.key;
1233
+ const q = getQuality(key);
1234
+ if (q?.maxRating) {
1235
+ const r = Math.max(1, Math.min(q.maxRating, Math.floor(rating ?? resolved.rating)));
1236
+ this.qualityRatings.set(key, r);
1237
+ }
1193
1238
  if (this.qualities.has(key))
1194
1239
  return;
1195
1240
  this.qualities.add(key);
@@ -1415,7 +1460,8 @@ export class Player extends AbstractPlayer {
1415
1460
  ears: null,
1416
1461
  mouth: null,
1417
1462
  nose: null,
1418
- commlink: null
1463
+ commlink: null,
1464
+ trodes: null
1419
1465
  },
1420
1466
  torso: {
1421
1467
  clothing: null,
@@ -1423,31 +1469,26 @@ export class Player extends AbstractPlayer {
1423
1469
  armorCoat: null,
1424
1470
  backpack: null,
1425
1471
  tacticalRig: null,
1426
- leftShoulder: null,
1427
- rightShoulder: null,
1472
+ shoulder: null,
1428
1473
  bandolier: null
1429
1474
  },
1430
1475
  legs: {
1431
1476
  pants: null,
1432
1477
  thighRig: null,
1433
- leftHip: null,
1434
- rightHip: null
1478
+ hip: null
1435
1479
  },
1436
1480
  feet: {
1437
1481
  boots: null,
1438
- leftAnkle: null,
1439
- rightAnkle: null
1482
+ ankle: null
1440
1483
  },
1441
1484
  arms: {
1442
- leftBicept: null,
1443
- rightBicept: null,
1444
- leftForearm: null,
1445
- rightForearm: null,
1485
+ bicep: null,
1486
+ forearm: null,
1446
1487
  cyberware: null
1447
1488
  },
1448
1489
  gloves: {
1449
- leftWrist: null,
1450
- rightWrist: null
1490
+ gloves: null,
1491
+ wrist: null
1451
1492
  },
1452
1493
  leftHand: {
1453
1494
  weapon: null,
@@ -1642,8 +1683,143 @@ export class Player extends AbstractPlayer {
1642
1683
  this.logger.write("Failed to add item to inventory due to weight limits.");
1643
1684
  throw new Error("The item is too heavy to add to your inventory.");
1644
1685
  }
1686
+ // A PROGRAM CHIP IS CONSUMED BY THE DECK (p.227, p.243): programs are
1687
+ // files in deck storage, not gear in the pack. With a deck on you the
1688
+ // chip installs and is gone; with none it waits in the bag for one.
1689
+ // A PAPER GOES ON THE COMMLINK (p.443): with a device on you it lands
1690
+ // there and never touches the pack; a license links to the SIN you
1691
+ // present. With no device it waits in the bag like a chip does.
1692
+ if (item.isPaper()) {
1693
+ const device = this.paperDevice();
1694
+ if (device) {
1695
+ this.filePaper(device, item);
1696
+ this.logger.write(`${item.name} filed on ${device.name}${item.linkedSin ? ` (linked to ${item.linkedSin})` : ''}.`);
1697
+ this.noteConditionChanged();
1698
+ return;
1699
+ }
1700
+ }
1701
+ const key = item.programKey ?? item.softKey;
1702
+ const deck = key ? (item.softKey ? this.softDevice() : this.getAnyCyberdeck()) : undefined;
1703
+ if (key && deck) {
1704
+ const fresh = deck.installProgram(key);
1705
+ this.logger.write(`${item.name} ${fresh ? 'installed into' : 'already on'} ${deck.name} -- chip consumed.`);
1706
+ this.noteConditionChanged();
1707
+ return;
1708
+ }
1645
1709
  this._inventory.addItem(item);
1646
1710
  this.logger.write(`Added ${item.name} to ${this.name}'s inventory.`);
1711
+ // A new deck takes every chip that was waiting for it.
1712
+ if (item.isCyberdeck() || item.category === Category.Commlink) {
1713
+ this.sweepProgramChipsIntoDeck();
1714
+ this.sweepPapersOntoDevice();
1715
+ }
1716
+ }
1717
+ /** Where papers live (p.443): the commlink, else the deck -- the PAN's master. */
1718
+ paperDevice() {
1719
+ return this.commlinks()[0] ?? this.getAnyCyberdeck();
1720
+ }
1721
+ /** Every device that can hold papers, commlinks first. */
1722
+ paperDevices() {
1723
+ const deck = this.getAnyCyberdeck();
1724
+ return [...this.commlinks(), ...(deck ? [deck] : [])];
1725
+ }
1726
+ /** Every paper on every device. */
1727
+ papers() {
1728
+ return this.paperDevices().flatMap(d => d.papers);
1729
+ }
1730
+ /** Files a paper on a device; a license links to the presented fake SIN (utilities/sin.ts resolves "presented"). */
1731
+ filePaper(device, paper) {
1732
+ if (paper.category === Category.License && !paper.linkedSin) {
1733
+ const sins = this.papers().filter(p => p.category === Category.FakeSIN);
1734
+ const chosen = sins.find(p => p.name === this.activeSin) ?? sins[0];
1735
+ if (chosen)
1736
+ paper.linkedSin = chosen.name;
1737
+ }
1738
+ device.papers.push(paper);
1739
+ // A SIN arriving after an unlinked license takes it.
1740
+ if (paper.category === Category.FakeSIN) {
1741
+ for (const lic of this.papers())
1742
+ if (lic.category === Category.License && !lic.linkedSin)
1743
+ lic.linkedSin = paper.name;
1744
+ }
1745
+ }
1746
+ /** Burns a fake SIN: gone from its device, and every license linked to it goes with it (p.443). Returns the licenses lost. */
1747
+ burnPaper(sin) {
1748
+ const lost = [];
1749
+ for (const device of this.paperDevices()) {
1750
+ device.papers = device.papers.filter(p => {
1751
+ if (p === sin)
1752
+ return false;
1753
+ if (p.category === Category.License && p.linkedSin === sin.name) {
1754
+ lost.push(p);
1755
+ return false;
1756
+ }
1757
+ return true;
1758
+ });
1759
+ }
1760
+ if (this.activeSin === sin.name)
1761
+ this.activeSin = undefined;
1762
+ this.noteConditionChanged();
1763
+ return lost;
1764
+ }
1765
+ /** Every paper in the pack files onto the carried device; returns how many moved. */
1766
+ sweepPapersOntoDevice() {
1767
+ const device = this.paperDevice();
1768
+ if (!device)
1769
+ return 0;
1770
+ let n = 0;
1771
+ // SINs first, so a loose license has one to link to.
1772
+ const loose = [...this._inventory.getAllItems()].filter(i => i.isPaper())
1773
+ .sort((a, b) => (a.category === Category.License ? 1 : 0) - (b.category === Category.License ? 1 : 0));
1774
+ for (const paper of loose) {
1775
+ this._inventory.removeItem(paper.name);
1776
+ this.filePaper(device, paper);
1777
+ n++;
1778
+ }
1779
+ if (n > 0)
1780
+ this.noteConditionChanged();
1781
+ return n;
1782
+ }
1783
+ /** The commlinks on this runner: worn on the head, or in the pack. */
1784
+ commlinks() {
1785
+ const worn = this._equipment.head?.commlink;
1786
+ return [...(worn ? [worn] : []), ...this._inventory.getAllItems().filter(i => i.category === Category.Commlink)];
1787
+ }
1788
+ /** Where a data soft lives (p.441-442): the deck if there is one, else a commlink. */
1789
+ softDevice() {
1790
+ return this.getAnyCyberdeck() ?? this.commlinks()[0];
1791
+ }
1792
+ /** A soft in the storage of any device on you -- a chip in the bag is not installed. */
1793
+ hasSoft(key) {
1794
+ const deck = this.getAnyCyberdeck();
1795
+ return [...(deck ? [deck] : []), ...this.commlinks()].some(d => d.hasStored(key));
1796
+ }
1797
+ /**
1798
+ * THE SOCIAL LIMIT (SR5 p.100-101): [(Charisma x 2) + Willpower + Essence]
1799
+ * / 3, rounded up, with Essence rounded up first (p.95, p.100 -- the
1800
+ * rounding rule is cited; the table row itself the RAG did not return,
1801
+ * see rag-table-rows-missing). A Shopsoft adds one when buying or
1802
+ * selling what it covers (p.442) -- see commands/haggle.ts.
1803
+ */
1804
+ get socialLimit() {
1805
+ return Math.ceil((this.charisma * 2 + this.willpower + Math.ceil(this.essence - 1e-9)) / 3);
1806
+ }
1807
+ /** Every program chip in the pack installs into the carried deck; returns how many chips were consumed. */
1808
+ sweepProgramChipsIntoDeck() {
1809
+ const deck = this.getAnyCyberdeck();
1810
+ const softHome = this.softDevice();
1811
+ let n = 0;
1812
+ for (const chip of [...this._inventory.getAllItems()]) {
1813
+ const device = chip.programKey ? deck : chip.softKey ? softHome : undefined;
1814
+ if (!device)
1815
+ continue;
1816
+ device.installProgram((chip.programKey ?? chip.softKey));
1817
+ this._inventory.removeItem(chip.name);
1818
+ n++;
1819
+ }
1820
+ if (n > 0)
1821
+ this.noteConditionChanged();
1822
+ return n;
1647
1823
  }
1648
1824
  removeInventory(item) {
1649
1825
  if (this._inventory.removeItem(item.name)) {
@@ -1660,7 +1836,7 @@ export class Player extends AbstractPlayer {
1660
1836
  if (!this.canEquip(item)) {
1661
1837
  return `You can't equip ${item.name}.`;
1662
1838
  }
1663
- const slotInfo = this.determineEquipmentSlot(item.category);
1839
+ const slotInfo = this.slotForItem(item);
1664
1840
  if (!slotInfo) {
1665
1841
  return `${item.name} doesn't fit any known equipment slot.`;
1666
1842
  }
@@ -1673,7 +1849,7 @@ export class Player extends AbstractPlayer {
1673
1849
  let swapNotice = '';
1674
1850
  if (previousItem) {
1675
1851
  if (previousItem.name === item.name) {
1676
- return `You already have ${item.name} equipped in your ${group}.${slot} slot.`;
1852
+ return `You already have ${item.name} on -- ${Player.slotWord(group, slot)}.`;
1677
1853
  }
1678
1854
  this.unequipBySlot(group, slot);
1679
1855
  swapNotice = ` (unequipped ${previousItem.name})`;
@@ -1687,7 +1863,21 @@ export class Player extends AbstractPlayer {
1687
1863
  this.weaponDrawn = false;
1688
1864
  return `You slide ${item.name} into your holster.${swapNotice}${hint(` "brandish" when you want it in hand.`)}`;
1689
1865
  }
1690
- return `You equipped ${item.name} in your ${group}.${slot} slot.${swapNotice}`;
1866
+ return `You put on ${item.name} -- ${Player.slotWord(group, slot)}.${swapNotice}`;
1867
+ }
1868
+ /** The word a player would use for a slot -- never the key ("body armor", not "torso.armorVest"). */
1869
+ static SLOT_WORDS = {
1870
+ head: { eyes: 'eyes', skull: 'head', ears: 'ears', mouth: 'face', nose: 'nose', commlink: 'commlink', trodes: 'trode net' },
1871
+ torso: { clothing: 'clothes', armorVest: 'body armor', armorCoat: 'coat', backpack: 'backpack', tacticalRig: 'rig', shoulder: 'shoulder', bandolier: 'bandolier' },
1872
+ legs: { pants: 'legs', thighRig: 'thigh rig', hip: 'hip' },
1873
+ feet: { boots: 'feet', ankle: 'ankle' },
1874
+ arms: { forearm: 'forearm', bicep: 'upper arm', cyberware: 'arm implant' },
1875
+ gloves: { gloves: 'hands', wrist: 'wrist' },
1876
+ leftHand: { weapon: 'off hand', shield: 'shield arm', ring: 'ring', device: 'off hand' },
1877
+ rightHand: { weapon: 'weapon hand', ring: 'ring', device: 'hand' },
1878
+ };
1879
+ static slotWord(group, slot) {
1880
+ return Player.SLOT_WORDS[group]?.[slot] ?? `${group}.${slot}`;
1691
1881
  }
1692
1882
  /** True for the hand slots that hold a wielded weapon. */
1693
1883
  static isWeaponSlot(group, slot) {
@@ -1711,6 +1901,23 @@ export class Player extends AbstractPlayer {
1711
1901
  }
1712
1902
  return `No equipped item matches "${input}".`;
1713
1903
  }
1904
+ /**
1905
+ * The slot an ITEM wears in: the category decides (determineEquipmentSlot)
1906
+ * except where the name has to -- a trode net is Electronics like a
1907
+ * flashlight, and the flashlight has no slot. Kit devices (a deck, a
1908
+ * drone, uninstalled 'ware) answer null: they are carried, not worn.
1909
+ */
1910
+ slotForItem(item) {
1911
+ if (Player.isTrodeNet(item))
1912
+ return { group: 'head', slot: 'trodes' };
1913
+ if (item.category === Category.Cyberdeck || item.category === Category.Drone || item.category === Category.Cyberware)
1914
+ return null;
1915
+ return this.determineEquipmentSlot(item.category);
1916
+ }
1917
+ /** Everything in the bag that has a slot and is not in it -- armor left in the pack (a real save: Armor Clothing carried, nothing worn). */
1918
+ wearablesInBag() {
1919
+ return this._inventory.getAllItems().filter(i => this.slotForItem(i) !== null);
1920
+ }
1714
1921
  determineEquipmentSlot(input) {
1715
1922
  const normalized = input.toLowerCase();
1716
1923
  switch (normalized) {
@@ -1752,10 +1959,10 @@ export class Player extends AbstractPlayer {
1752
1959
  return { group: 'torso', slot: 'tacticalRig' };
1753
1960
  case 'backpack':
1754
1961
  return { group: 'torso', slot: 'backpack' };
1962
+ case 'shoulder holster':
1755
1963
  case 'left shoulder holster':
1756
- return { group: 'torso', slot: 'leftShoulder' };
1757
1964
  case 'right shoulder holster':
1758
- return { group: 'torso', slot: 'rightShoulder' };
1965
+ return { group: 'torso', slot: 'shoulder' };
1759
1966
  case 'bandolier':
1760
1967
  return { group: 'torso', slot: 'bandolier' };
1761
1968
  // LEGS
@@ -1766,39 +1973,38 @@ export class Player extends AbstractPlayer {
1766
1973
  case 'thigh rig':
1767
1974
  case 'thigh holster':
1768
1975
  return { group: 'legs', slot: 'thighRig' };
1976
+ case 'hip pouch':
1769
1977
  case 'left hip pouch':
1770
- return { group: 'legs', slot: 'leftHip' };
1771
1978
  case 'right hip pouch':
1772
- return { group: 'legs', slot: 'rightHip' };
1979
+ return { group: 'legs', slot: 'hip' };
1773
1980
  // FEET
1774
1981
  case Category.Boots:
1775
1982
  case 'combat boots':
1776
1983
  case 'shoes':
1777
1984
  return { group: 'feet', slot: 'boots' };
1985
+ case 'anklet':
1778
1986
  case 'left anklet':
1779
- return { group: 'feet', slot: 'leftAnkle' };
1780
1987
  case 'right anklet':
1781
- return { group: 'feet', slot: 'rightAnkle' };
1988
+ return { group: 'feet', slot: 'ankle' };
1782
1989
  // ARMS
1783
1990
  case Category.ArmArmor:
1784
1991
  case 'arm sleeve':
1992
+ case 'bracer':
1785
1993
  case 'left forearm implant':
1786
- return { group: 'arms', slot: 'leftForearm' };
1787
1994
  case 'right forearm implant':
1788
- return { group: 'arms', slot: 'rightForearm' };
1995
+ return { group: 'arms', slot: 'forearm' };
1996
+ case 'armband':
1789
1997
  case 'left armband':
1790
- return { group: 'arms', slot: 'leftBicept' };
1791
1998
  case 'right armband':
1792
- return { group: 'arms', slot: 'rightBicept' };
1999
+ return { group: 'arms', slot: 'bicep' };
1793
2000
  case Category.Cyberware:
1794
2001
  return { group: 'arms', slot: 'cyberware' };
1795
2002
  // GLOVES / WRIST
1796
2003
  case Category.Gloves:
1797
2004
  case 'glove':
1798
2005
  case 'left glove':
1799
- return { group: 'gloves', slot: 'leftWrist' };
1800
2006
  case 'right glove':
1801
- return { group: 'gloves', slot: 'rightWrist' };
2007
+ return { group: 'gloves', slot: 'gloves' };
1802
2008
  // WRIST-WORN gear -- a DocWagon band, a smartwatch, a med
1803
2009
  // bracelet (player note 2026-08-25: "docwagon wristband should
1804
2010
  // be wearable"). No new equipment GROUP was needed: the wrist
@@ -1811,9 +2017,9 @@ export class Player extends AbstractPlayer {
1811
2017
  case 'wristband':
1812
2018
  case 'bracelet':
1813
2019
  case 'watch':
1814
- return { group: 'gloves', slot: 'leftWrist' };
2020
+ case 'left wrist':
1815
2021
  case 'right wrist':
1816
- return { group: 'gloves', slot: 'rightWrist' };
2022
+ return { group: 'gloves', slot: 'wrist' };
1817
2023
  // LEFT HAND
1818
2024
  case Category.Shield:
1819
2025
  return { group: 'leftHand', slot: 'shield' };
@@ -1846,14 +2052,14 @@ export class Player extends AbstractPlayer {
1846
2052
  }
1847
2053
  const itemToUnequip = groupRef[slot];
1848
2054
  if (!itemToUnequip) {
1849
- return `Nothing is equipped in the ${group}.${slot} slot.`;
2055
+ return `Nothing on your ${Player.slotWord(group, slot)}.`;
1850
2056
  }
1851
2057
  this.addInventory(itemToUnequip);
1852
2058
  groupRef[slot] = null;
1853
2059
  // No weapon, nothing drawn -- keeps the flag honest for the next equip.
1854
2060
  if (Player.isWeaponSlot(group, slot))
1855
2061
  this.weaponDrawn = false;
1856
- return `You unequipped ${itemToUnequip.name} from your ${group}.${slot} slot.`;
2062
+ return `You take off ${itemToUnequip.name} (${Player.slotWord(group, slot)}) and bag it.`;
1857
2063
  }
1858
2064
  hear(actor, message, opts) {
1859
2065
  // Cross-plane silence: a Matrix persona's chatter never reaches meat
@@ -2151,25 +2357,51 @@ export class Player extends AbstractPlayer {
2151
2357
  // point, and trim (boots, gloves, arm/leg pieces) is flavor, not
2152
2358
  // dice -- stacking four rated accessories used to out-armor the
2153
2359
  // jacket they hung off.
2154
- let total = 0;
2360
+ return this.armorBreakdown().total;
2361
+ }
2362
+ /**
2363
+ * ARMOR DOES NOT STACK (SR5 p.169, RAG-checked 2026-09-02): "only the
2364
+ * highest Armor rating applies; all other pieces only add bulk". A helmet
2365
+ * or shield is a "+" accessory and adds to that one piece (p.438). The
2366
+ * old sum let a vest under a coat out-armor either -- the same deviation
2367
+ * the trim rule below already closed for boots and gloves. `ignored` is
2368
+ * what the player is wearing for nothing, so the panel can say so.
2369
+ */
2370
+ armorBreakdown() {
2371
+ let piece = null;
2372
+ let best = 0;
2373
+ const extras = [];
2374
+ const ignored = [];
2155
2375
  for (const group of Object.values(this._equipment)) {
2156
2376
  if (!group)
2157
2377
  continue;
2158
2378
  for (const item of Object.values(group)) {
2159
2379
  if (!item || !Player.ARMOR_CATEGORIES.has(item.category))
2160
2380
  continue;
2161
- if (item.category === Category.Helmet)
2162
- total += 1;
2163
- else if (item.category === Category.Shield)
2164
- total += 2;
2165
- else if (item.category === Category.Boots || item.category === Category.Gloves
2381
+ if (item.category === Category.Helmet) {
2382
+ extras.push({ item, bonus: 1 });
2383
+ continue;
2384
+ }
2385
+ if (item.category === Category.Shield) {
2386
+ extras.push({ item, bonus: 2 });
2387
+ continue;
2388
+ }
2389
+ if (item.category === Category.Boots || item.category === Category.Gloves
2166
2390
  || item.category === Category.ArmArmor || item.category === Category.LegArmor)
2167
- total += 0;
2168
- else
2169
- total += 1 + item.qualityBonus + (item.isHeavyArmor ? 1 : 0);
2391
+ continue;
2392
+ const value = 1 + item.qualityBonus + (item.isHeavyArmor ? 1 : 0);
2393
+ if (value > best) {
2394
+ if (piece)
2395
+ ignored.push(piece);
2396
+ piece = item;
2397
+ best = value;
2398
+ }
2399
+ else {
2400
+ ignored.push(item);
2401
+ }
2170
2402
  }
2171
2403
  }
2172
- return total;
2404
+ return { total: best + extras.reduce((n, e) => n + e.bonus, 0), piece, extras, ignored };
2173
2405
  }
2174
2406
  getAttackPool() {
2175
2407
  // Plane decides which attribute drives the fight: meat is muscle
@@ -2863,6 +3095,15 @@ export class Player extends AbstractPlayer {
2863
3095
  get formDefenseBonus() {
2864
3096
  return this.sustainedForms.some(f => f.key === 'static-veil') ? 2 : 0;
2865
3097
  }
3098
+ /** The Force each sustained spell was cast at (cast.ts records it) -- Focused Concentration reads it. */
3099
+ sustainedForces = new Map();
3100
+ noteSustainedForce(kind, force) {
3101
+ this.sustainedForces.set(kind, force);
3102
+ }
3103
+ /** GREMLINS (p.81): the 1s a glitch needs, one fewer per rating, on external equipment -- the deck. */
3104
+ get deckGremlins() {
3105
+ return this.isTechnomancer() ? 0 : this.qualityRating('gremlins');
3106
+ }
2866
3107
  get sustainingPenalty() {
2867
3108
  // Each sustained effect costs -2 dice across the board: the armor
2868
3109
  // spell, invisibility, hot reflexes, and open astral senses (see
@@ -2871,11 +3112,22 @@ export class Player extends AbstractPlayer {
2871
3112
  // QUICKENING (metamagic, p.326 adapted): the armor spell anchors into
2872
3113
  // the world and sustains itself -- its -2 simply never bills.
2873
3114
  const armorCost = this._sustainedArmor > 0 && !this.hasMetamagic('quickening') ? 2 : 0;
2874
- return armorCost
2875
- + (this.sustainedInvisibility ? 2 : 0)
2876
- + (this.sustainedReflexes ? 2 : 0)
2877
- + (this.sustainedBarrier ? 2 : 0)
2878
- + (this.sustainedManaBarrier ? 2 : 0)
3115
+ // FOCUSED CONCENTRATION (p.74, RAG-checked 2026-09-02): "sustain one
3116
+ // spell with a Force equal to or less than your rating without
3117
+ // penalty". One spell, whose Force the cast recorded; a spell with
3118
+ // no recorded Force is not eligible (honest, not generous).
3119
+ const spells = [
3120
+ { cost: armorCost, force: this.sustainedForces.get('armor') },
3121
+ { cost: this.sustainedInvisibility ? 2 : 0, force: this.sustainedForces.get('invisibility') },
3122
+ { cost: this.sustainedReflexes ? 2 : 0, force: this.sustainedForces.get('reflexes') },
3123
+ { cost: this.sustainedBarrier ? 2 : 0, force: this.sustainedBarrier?.force },
3124
+ { cost: this.sustainedManaBarrier ? 2 : 0, force: this.sustainedManaBarrier?.force },
3125
+ ];
3126
+ const focus = this.qualityRating('focused-concentration');
3127
+ const waived = focus > 0 ? spells.find(sp => sp.cost > 0 && sp.force !== undefined && sp.force <= focus) : undefined;
3128
+ if (waived)
3129
+ waived.cost = 0;
3130
+ return spells.reduce((n, sp) => n + sp.cost, 0)
2879
3131
  + (this.astralPerceiving ? 2 : 0)
2880
3132
  + (this.dumpshocked ? 2 : 0)
2881
3133
  + this.sustainedForms.length * 2;