@maka/maka-cli 5.117.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 (159) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +6 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +9 -2
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +3 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.d.ts +14 -2
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js +73 -38
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -6
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js.map +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +4 -3
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js.map +1 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.d.ts +1 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +10 -8
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +17 -7
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.d.ts +10 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +72 -20
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +8 -3
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +30 -14
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +14 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.d.ts +16 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +95 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js.map +1 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +6 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +3 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/jackpoint.js +5 -7
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/jackpoint.js.map +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +24 -4
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +20 -6
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js.map +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js +7 -6
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js.map +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +6 -3
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +3 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +8 -5
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +4 -3
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/sin.js +11 -3
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/sin.js.map +1 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +5 -3
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +20 -2
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +1 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +2 -5
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.d.ts +6 -0
  70. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js +17 -4
  71. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +1 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +2 -0
  75. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  76. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +22 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/game.js +132 -29
  78. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/models/host.d.ts +80 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +98 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/models/host.js.map +1 -0
  82. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +50 -1
  83. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +78 -2
  84. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +133 -3
  86. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +381 -85
  87. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  88. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +40 -9
  89. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +73 -12
  90. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  91. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +3 -0
  92. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +3 -0
  93. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  94. package/bundle/typescript/src/commands/game/sideQuest/qualities.d.ts +34 -1
  95. package/bundle/typescript/src/commands/game/sideQuest/qualities.js +58 -26
  96. package/bundle/typescript/src/commands/game/sideQuest/qualities.js.map +1 -1
  97. package/bundle/typescript/src/commands/game/sideQuest/types/player-equipment.d.ts +8 -12
  98. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +14 -1
  99. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +1 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +2 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.d.ts +22 -16
  102. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.js +22 -16
  103. package/bundle/typescript/src/commands/game/sideQuest/types/shared/equipment-enum.js.map +1 -1
  104. package/bundle/typescript/src/commands/game/sideQuest/ui.js +2 -2
  105. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  106. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +13 -8
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +1 -1
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +8 -7
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -1
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +61 -16
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.d.ts +23 -0
  114. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +111 -11
  115. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js.map +1 -1
  116. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.d.ts +5 -1
  117. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +5 -27
  118. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js.map +1 -1
  119. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +2 -1
  120. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js.map +1 -1
  121. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.d.ts +11 -0
  122. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +56 -8
  123. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js.map +1 -1
  124. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.d.ts +72 -0
  125. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +100 -0
  126. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js.map +1 -0
  127. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.d.ts +25 -0
  128. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.js +10 -0
  129. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-note.js.map +1 -0
  130. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.d.ts +29 -0
  131. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.js +64 -0
  132. package/bundle/typescript/src/commands/game/sideQuest/utilities/harm-settlement.js.map +1 -0
  133. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-actors.d.ts +9 -0
  134. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-actors.js +98 -0
  135. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-actors.js.map +1 -0
  136. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.d.ts +3 -2
  137. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +34 -6
  138. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js.map +1 -1
  139. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.d.ts +42 -2
  140. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +75 -4
  141. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js.map +1 -1
  142. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-roster.d.ts +62 -0
  143. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-roster.js +97 -0
  144. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-roster.js.map +1 -0
  145. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +3 -3
  146. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js.map +1 -1
  147. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.d.ts +1 -3
  148. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +85 -7
  149. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  150. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +10 -0
  151. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +39 -23
  152. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  153. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.d.ts +16 -0
  154. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +12 -0
  155. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js.map +1 -1
  156. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.d.ts +2 -0
  157. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +23 -4
  158. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js.map +1 -1
  159. 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,45 +12,22 @@ 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';
18
25
  }
19
- /**
20
- * ARE THESE TWO STANDING ON THE SAME SIDE OF A HOST'S WALL?
21
- *
22
- * The companion to fileReach, and it exists for the same reason: the
23
- * boundary was being asked about in more than one place and the copies
24
- * disagreed. fileReach settled it for FILES; nothing settled it for
25
- * ICONS, so `look` honoured insideHost while every verb that picks a
26
- * target read the room's raw actor list and saw straight through the
27
- * wall. A technomancer standing inside a host was told "Empty
28
- * architecture -- turning geometry and nothing in it worth the trip" by
29
- * one surface and offered those same two IC as spike targets by the
30
- * next.
31
- *
32
- * CANON MAKES THE PANEL BINDING, not the target list. Inside a host you
33
- * see and interact with the icons inside it and not the ones outside,
34
- * and from the grid the inside is unreachable (p.246) -- and threading
35
- * adds its own half of the same rule: "You can only use complex forms
36
- * on icons you've spotted" (p.251). An icon the room refuses to name is
37
- * an icon nobody has spotted, so targeting it was never legal.
38
- *
39
- * BOTH IN THE SAME ROOM IS THE PRECONDITION, and every caller has it --
40
- * these are room-scoped target lists. Given that, comparing insideHost
41
- * directly is the whole rule: both undefined means both are out on the
42
- * open grid, both equal to the room means both are inside its
43
- * architecture. There is deliberately no third state.
44
- *
45
- * MEAT AND ASTRAL ACTORS PASS TRIVIALLY, which is correct and not an
46
- * oversight: insideHost is persona state, so a body in the room has
47
- * none. The plane filters the callers already apply are what keep those
48
- * actors out of a Matrix target list; this function answers one question
49
- * and is not a second plane check.
50
- */
51
26
  export function sameHostSide(actor, other) {
52
- return actor.insideHost === other.insideHost;
27
+ // A real Player answers with its Host; a bare fixture ({ insideHost })
28
+ // answers with whatever it carries. Both sides read the same way.
29
+ const sideOf = (p) => p.hostInside ?? p.insideHost;
30
+ return sideOf(actor) === sideOf(other);
53
31
  }
54
32
  export const SKILL_NAMES = [
55
33
  'blades', 'clubs', 'unarmed', 'archery', 'free-fall',
@@ -426,7 +404,68 @@ export class Player extends AbstractPlayer {
426
404
  * Session state like hostAlert and linkLockedBy: leaving the Matrix
427
405
  * ends it, so nothing here is persisted.
428
406
  */
429
- insideHost;
407
+ /**
408
+ * WHERE THE PERSONA IS (increment 2 of the Matrix position model): on a
409
+ * grid, or inside a host it entered from one. Session-only, like
410
+ * bodyRoom. The body stays in currentLocation on every plane -- the
411
+ * persona never writes it. The setter fires the live-sheet beat, which
412
+ * is the beat the currentLocation setter used to fire on enter/exit.
413
+ * `grid` may be unknown (fixtures, and increment 2 itself): unknown is
414
+ * not "another grid", so it costs nothing.
415
+ */
416
+ _matrixPosition;
417
+ get matrixPosition() { return this._matrixPosition; }
418
+ set matrixPosition(v) {
419
+ const same = v === this._matrixPosition
420
+ || (v !== undefined && this._matrixPosition !== undefined && v.kind === this._matrixPosition.kind
421
+ && (v.kind === 'host' ? v.host === this._matrixPosition.host
422
+ : v.grid?.key === this._matrixPosition.grid?.key));
423
+ if (same)
424
+ return;
425
+ this._matrixPosition = v;
426
+ this.noteConditionChanged();
427
+ }
428
+ /** The grid the persona rides, when it is on one. */
429
+ get currentGrid() {
430
+ return this._matrixPosition?.kind === 'grid' ? this._matrixPosition.grid : undefined;
431
+ }
432
+ /** The host the persona is inside, when it is inside one. */
433
+ get hostInside() {
434
+ return this._matrixPosition?.kind === 'host' ? this._matrixPosition.host : undefined;
435
+ }
436
+ /**
437
+ * @deprecated compat through increment 4 -- read hostInside / write
438
+ * matrixPosition. The room a host stands over, when inside one. The
439
+ * setter still builds the position so every old writer keeps working.
440
+ */
441
+ _compatInsideRoom;
442
+ get insideHost() { return this.hostInside?.over ?? this._compatInsideRoom; }
443
+ set insideHost(room) {
444
+ if (room) {
445
+ // A room you are inside of IS a host. Fixtures (and older callers)
446
+ // set insideHost on a plain Room; install the Host they meant.
447
+ if (!room.host)
448
+ room.hasNode = true;
449
+ const host = room.host;
450
+ if (!host) {
451
+ // Not a real Room (a fixture stub): remember it so the getter and
452
+ // sameHostSide still answer, without a position to stand on.
453
+ this._compatInsideRoom = room;
454
+ return;
455
+ }
456
+ this._compatInsideRoom = undefined;
457
+ this.matrixPosition = { kind: 'host', host, from: this.currentGrid ?? this.lastGrid };
458
+ }
459
+ else {
460
+ this._compatInsideRoom = undefined;
461
+ if (this._matrixPosition?.kind === 'host')
462
+ this.matrixPosition = { kind: 'grid', grid: this._matrixPosition.from };
463
+ }
464
+ }
465
+ /** The grid last stood on, so an exit built through the old setter returns there. */
466
+ get lastGrid() {
467
+ return this._matrixPosition?.kind === 'host' ? this._matrixPosition.from : this._matrixPosition?.grid;
468
+ }
430
469
  /**
431
470
  * MATRIX ATTRIBUTES BURNED OFF BY IC (p.248): Acid eats Firewall,
432
471
  * Binder eats Data Processing, Jammer eats Attack, Marker eats Sleaze.
@@ -544,7 +583,29 @@ export class Player extends AbstractPlayer {
544
583
  return true;
545
584
  if (this.hasAug('datajack'))
546
585
  return true;
547
- 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;
548
609
  }
549
610
  /**
550
611
  * THE PRICE OF SILENCE (canon p.235-236 "Running Silent",
@@ -647,7 +708,13 @@ export class Player extends AbstractPlayer {
647
708
  dataProcessing: 'Data Processing', firewall: 'Firewall',
648
709
  };
649
710
  /** The undamaged array, before any IC reduction. */
711
+ /** Set on an IC actor (utilities/ic-actors.ts): "IC uses the Matrix attributes of its host" (p.247). */
712
+ icHost;
713
+ /** The IC kind an IC actor was launched as -- the name the host's running list carries. */
714
+ icKind;
650
715
  baseMatrixAttribute(kind) {
716
+ if (this.icHost)
717
+ return this.icHost.attributes()[kind];
651
718
  if (this.isTechnomancer()) {
652
719
  switch (kind) {
653
720
  case 'attack': return this.charisma;
@@ -922,7 +989,13 @@ export class Player extends AbstractPlayer {
922
989
  /** Routes Matrix combat damage into the jacked-in deck's hardware track. */
923
990
  takeMatrixDamage(boxes) {
924
991
  const deck = this.getAnyCyberdeck();
925
- return deck ? deck.takeDeckDamage(boxes) : 0;
992
+ const taken = deck ? deck.takeDeckDamage(boxes) : 0;
993
+ // DECK DAMAGE BEATS (web report 2026-09-02: "the damage to the deck is not moving in realtime"):
994
+ // the Item has no path to its owner, so the owner fires here, and the
995
+ // exchange and rest fire on their own deck writes.
996
+ if (taken > 0)
997
+ this.noteConditionChanged();
998
+ return taken;
926
999
  }
927
1000
  get matrixCrashed() {
928
1001
  return this.getAnyCyberdeck()?.isBricked ?? false;
@@ -1145,7 +1218,23 @@ export class Player extends AbstractPlayer {
1145
1218
  * the only quality that edits an attribute (RAW raises the Edge ceiling;
1146
1219
  * with no attribute point-buy here, the point lands directly).
1147
1220
  */
1148
- 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
+ }
1149
1238
  if (this.qualities.has(key))
1150
1239
  return;
1151
1240
  this.qualities.add(key);
@@ -1371,7 +1460,8 @@ export class Player extends AbstractPlayer {
1371
1460
  ears: null,
1372
1461
  mouth: null,
1373
1462
  nose: null,
1374
- commlink: null
1463
+ commlink: null,
1464
+ trodes: null
1375
1465
  },
1376
1466
  torso: {
1377
1467
  clothing: null,
@@ -1379,31 +1469,26 @@ export class Player extends AbstractPlayer {
1379
1469
  armorCoat: null,
1380
1470
  backpack: null,
1381
1471
  tacticalRig: null,
1382
- leftShoulder: null,
1383
- rightShoulder: null,
1472
+ shoulder: null,
1384
1473
  bandolier: null
1385
1474
  },
1386
1475
  legs: {
1387
1476
  pants: null,
1388
1477
  thighRig: null,
1389
- leftHip: null,
1390
- rightHip: null
1478
+ hip: null
1391
1479
  },
1392
1480
  feet: {
1393
1481
  boots: null,
1394
- leftAnkle: null,
1395
- rightAnkle: null
1482
+ ankle: null
1396
1483
  },
1397
1484
  arms: {
1398
- leftBicept: null,
1399
- rightBicept: null,
1400
- leftForearm: null,
1401
- rightForearm: null,
1485
+ bicep: null,
1486
+ forearm: null,
1402
1487
  cyberware: null
1403
1488
  },
1404
1489
  gloves: {
1405
- leftWrist: null,
1406
- rightWrist: null
1490
+ gloves: null,
1491
+ wrist: null
1407
1492
  },
1408
1493
  leftHand: {
1409
1494
  weapon: null,
@@ -1598,8 +1683,143 @@ export class Player extends AbstractPlayer {
1598
1683
  this.logger.write("Failed to add item to inventory due to weight limits.");
1599
1684
  throw new Error("The item is too heavy to add to your inventory.");
1600
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
+ }
1601
1709
  this._inventory.addItem(item);
1602
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;
1603
1823
  }
1604
1824
  removeInventory(item) {
1605
1825
  if (this._inventory.removeItem(item.name)) {
@@ -1616,7 +1836,7 @@ export class Player extends AbstractPlayer {
1616
1836
  if (!this.canEquip(item)) {
1617
1837
  return `You can't equip ${item.name}.`;
1618
1838
  }
1619
- const slotInfo = this.determineEquipmentSlot(item.category);
1839
+ const slotInfo = this.slotForItem(item);
1620
1840
  if (!slotInfo) {
1621
1841
  return `${item.name} doesn't fit any known equipment slot.`;
1622
1842
  }
@@ -1629,7 +1849,7 @@ export class Player extends AbstractPlayer {
1629
1849
  let swapNotice = '';
1630
1850
  if (previousItem) {
1631
1851
  if (previousItem.name === item.name) {
1632
- return `You already have ${item.name} equipped in your ${group}.${slot} slot.`;
1852
+ return `You already have ${item.name} on -- ${Player.slotWord(group, slot)}.`;
1633
1853
  }
1634
1854
  this.unequipBySlot(group, slot);
1635
1855
  swapNotice = ` (unequipped ${previousItem.name})`;
@@ -1643,7 +1863,21 @@ export class Player extends AbstractPlayer {
1643
1863
  this.weaponDrawn = false;
1644
1864
  return `You slide ${item.name} into your holster.${swapNotice}${hint(` "brandish" when you want it in hand.`)}`;
1645
1865
  }
1646
- 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}`;
1647
1881
  }
1648
1882
  /** True for the hand slots that hold a wielded weapon. */
1649
1883
  static isWeaponSlot(group, slot) {
@@ -1667,6 +1901,23 @@ export class Player extends AbstractPlayer {
1667
1901
  }
1668
1902
  return `No equipped item matches "${input}".`;
1669
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
+ }
1670
1921
  determineEquipmentSlot(input) {
1671
1922
  const normalized = input.toLowerCase();
1672
1923
  switch (normalized) {
@@ -1708,10 +1959,10 @@ export class Player extends AbstractPlayer {
1708
1959
  return { group: 'torso', slot: 'tacticalRig' };
1709
1960
  case 'backpack':
1710
1961
  return { group: 'torso', slot: 'backpack' };
1962
+ case 'shoulder holster':
1711
1963
  case 'left shoulder holster':
1712
- return { group: 'torso', slot: 'leftShoulder' };
1713
1964
  case 'right shoulder holster':
1714
- return { group: 'torso', slot: 'rightShoulder' };
1965
+ return { group: 'torso', slot: 'shoulder' };
1715
1966
  case 'bandolier':
1716
1967
  return { group: 'torso', slot: 'bandolier' };
1717
1968
  // LEGS
@@ -1722,39 +1973,38 @@ export class Player extends AbstractPlayer {
1722
1973
  case 'thigh rig':
1723
1974
  case 'thigh holster':
1724
1975
  return { group: 'legs', slot: 'thighRig' };
1976
+ case 'hip pouch':
1725
1977
  case 'left hip pouch':
1726
- return { group: 'legs', slot: 'leftHip' };
1727
1978
  case 'right hip pouch':
1728
- return { group: 'legs', slot: 'rightHip' };
1979
+ return { group: 'legs', slot: 'hip' };
1729
1980
  // FEET
1730
1981
  case Category.Boots:
1731
1982
  case 'combat boots':
1732
1983
  case 'shoes':
1733
1984
  return { group: 'feet', slot: 'boots' };
1985
+ case 'anklet':
1734
1986
  case 'left anklet':
1735
- return { group: 'feet', slot: 'leftAnkle' };
1736
1987
  case 'right anklet':
1737
- return { group: 'feet', slot: 'rightAnkle' };
1988
+ return { group: 'feet', slot: 'ankle' };
1738
1989
  // ARMS
1739
1990
  case Category.ArmArmor:
1740
1991
  case 'arm sleeve':
1992
+ case 'bracer':
1741
1993
  case 'left forearm implant':
1742
- return { group: 'arms', slot: 'leftForearm' };
1743
1994
  case 'right forearm implant':
1744
- return { group: 'arms', slot: 'rightForearm' };
1995
+ return { group: 'arms', slot: 'forearm' };
1996
+ case 'armband':
1745
1997
  case 'left armband':
1746
- return { group: 'arms', slot: 'leftBicept' };
1747
1998
  case 'right armband':
1748
- return { group: 'arms', slot: 'rightBicept' };
1999
+ return { group: 'arms', slot: 'bicep' };
1749
2000
  case Category.Cyberware:
1750
2001
  return { group: 'arms', slot: 'cyberware' };
1751
2002
  // GLOVES / WRIST
1752
2003
  case Category.Gloves:
1753
2004
  case 'glove':
1754
2005
  case 'left glove':
1755
- return { group: 'gloves', slot: 'leftWrist' };
1756
2006
  case 'right glove':
1757
- return { group: 'gloves', slot: 'rightWrist' };
2007
+ return { group: 'gloves', slot: 'gloves' };
1758
2008
  // WRIST-WORN gear -- a DocWagon band, a smartwatch, a med
1759
2009
  // bracelet (player note 2026-08-25: "docwagon wristband should
1760
2010
  // be wearable"). No new equipment GROUP was needed: the wrist
@@ -1767,9 +2017,9 @@ export class Player extends AbstractPlayer {
1767
2017
  case 'wristband':
1768
2018
  case 'bracelet':
1769
2019
  case 'watch':
1770
- return { group: 'gloves', slot: 'leftWrist' };
2020
+ case 'left wrist':
1771
2021
  case 'right wrist':
1772
- return { group: 'gloves', slot: 'rightWrist' };
2022
+ return { group: 'gloves', slot: 'wrist' };
1773
2023
  // LEFT HAND
1774
2024
  case Category.Shield:
1775
2025
  return { group: 'leftHand', slot: 'shield' };
@@ -1802,14 +2052,14 @@ export class Player extends AbstractPlayer {
1802
2052
  }
1803
2053
  const itemToUnequip = groupRef[slot];
1804
2054
  if (!itemToUnequip) {
1805
- return `Nothing is equipped in the ${group}.${slot} slot.`;
2055
+ return `Nothing on your ${Player.slotWord(group, slot)}.`;
1806
2056
  }
1807
2057
  this.addInventory(itemToUnequip);
1808
2058
  groupRef[slot] = null;
1809
2059
  // No weapon, nothing drawn -- keeps the flag honest for the next equip.
1810
2060
  if (Player.isWeaponSlot(group, slot))
1811
2061
  this.weaponDrawn = false;
1812
- return `You unequipped ${itemToUnequip.name} from your ${group}.${slot} slot.`;
2062
+ return `You take off ${itemToUnequip.name} (${Player.slotWord(group, slot)}) and bag it.`;
1813
2063
  }
1814
2064
  hear(actor, message, opts) {
1815
2065
  // Cross-plane silence: a Matrix persona's chatter never reaches meat
@@ -2107,25 +2357,51 @@ export class Player extends AbstractPlayer {
2107
2357
  // point, and trim (boots, gloves, arm/leg pieces) is flavor, not
2108
2358
  // dice -- stacking four rated accessories used to out-armor the
2109
2359
  // jacket they hung off.
2110
- 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 = [];
2111
2375
  for (const group of Object.values(this._equipment)) {
2112
2376
  if (!group)
2113
2377
  continue;
2114
2378
  for (const item of Object.values(group)) {
2115
2379
  if (!item || !Player.ARMOR_CATEGORIES.has(item.category))
2116
2380
  continue;
2117
- if (item.category === Category.Helmet)
2118
- total += 1;
2119
- else if (item.category === Category.Shield)
2120
- total += 2;
2121
- 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
2122
2390
  || item.category === Category.ArmArmor || item.category === Category.LegArmor)
2123
- total += 0;
2124
- else
2125
- 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
+ }
2126
2402
  }
2127
2403
  }
2128
- return total;
2404
+ return { total: best + extras.reduce((n, e) => n + e.bonus, 0), piece, extras, ignored };
2129
2405
  }
2130
2406
  getAttackPool() {
2131
2407
  // Plane decides which attribute drives the fight: meat is muscle
@@ -2819,6 +3095,15 @@ export class Player extends AbstractPlayer {
2819
3095
  get formDefenseBonus() {
2820
3096
  return this.sustainedForms.some(f => f.key === 'static-veil') ? 2 : 0;
2821
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
+ }
2822
3107
  get sustainingPenalty() {
2823
3108
  // Each sustained effect costs -2 dice across the board: the armor
2824
3109
  // spell, invisibility, hot reflexes, and open astral senses (see
@@ -2827,11 +3112,22 @@ export class Player extends AbstractPlayer {
2827
3112
  // QUICKENING (metamagic, p.326 adapted): the armor spell anchors into
2828
3113
  // the world and sustains itself -- its -2 simply never bills.
2829
3114
  const armorCost = this._sustainedArmor > 0 && !this.hasMetamagic('quickening') ? 2 : 0;
2830
- return armorCost
2831
- + (this.sustainedInvisibility ? 2 : 0)
2832
- + (this.sustainedReflexes ? 2 : 0)
2833
- + (this.sustainedBarrier ? 2 : 0)
2834
- + (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)
2835
3131
  + (this.astralPerceiving ? 2 : 0)
2836
3132
  + (this.dumpshocked ? 2 : 0)
2837
3133
  + this.sustainedForms.length * 2;