@maka/maka-cli 5.128.0 → 5.130.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.128.0",
3
+ "version": "5.130.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -2,7 +2,7 @@ import { Player } from './models/player.js';
2
2
  import { Item } from './models/item.js';
3
3
  import { Size, Category, Rating } from './types/shared/item-enum.js';
4
4
  import { augFor } from './utilities/catalog.js';
5
- import { creationSplit, getQuality, CREATION_KARMA, KARMA_CARRYOVER_MAX, KARMA_TO_NUYEN_MAX, KARMA_NUYEN_RATE, HELD_NUYEN_MAX } from './qualities.js';
5
+ import { creationSplit, getQuality, qualityKarma, CREATION_KARMA, KARMA_CARRYOVER_MAX, KARMA_TO_NUYEN_MAX, KARMA_NUYEN_RATE, HELD_NUYEN_MAX } from './qualities.js';
6
6
  import { catalogGearDef, catalogRowFor } from './utilities/catalog.js';
7
7
  export const ARCHETYPES = [
8
8
  {
@@ -103,6 +103,9 @@ export const ARCHETYPES = [
103
103
  adeptPowers: { 'improved-reflexes': 1, 'killing-hands': 1 },
104
104
  skills: { spellcasting: 5, counterspelling: 3, assensing: 3, unarmed: 4, blades: 3, gymnastics: 3, running: 3, swimming: 2, perception: 3, sneaking: 3, pistols: 2 } },
105
105
  qualities: ['focused-concentration', 'mentor-spirit', 'astral-beacon'],
106
+ // Rating 2 (p.74, 4 karma a rating): a Force 2 working held free.
107
+ // The ledger above priced rating 1; each rating past it is 4 more.
108
+ qualityRatings: { 'focused-concentration': 2 },
106
109
  gearSlugs: [
107
110
  'power-focus-1', 'reagents', 'armor-jacket', 'combat-boots', 'meta-link',
108
111
  'medkit-3', 'trauma-patch', 'stim-patch', 'ear-buds', 'glasses',
@@ -203,6 +206,9 @@ export const ARCHETYPES = [
203
206
  // into their dreams and calls it rent. Ledger 25 - 7 + 10 = 28 -> 7
204
207
  // karma + 20,000 nuyen.
205
208
  qualities: ['high-pain-tolerance', 'focused-concentration', 'mentor-spirit', 'quick-healer', 'insomnia'],
209
+ // Rating 2 (p.74, 4 karma a rating): a Force 2 working held free.
210
+ // The ledger above priced rating 1; each rating past it is 4 more.
211
+ qualityRatings: { 'focused-concentration': 2 },
206
212
  gearSlugs: [
207
213
  // A formula for a spell they do NOT know: that is what a formula
208
214
  // is for. Same 2,000 as the Combat category row it replaces, so
@@ -263,6 +269,10 @@ export const ARCHETYPES = [
263
269
  // who never let them look at the other half.
264
270
  // Ledger 25 - 9 = 16 -> 7 karma + 18,000 nuyen.
265
271
  qualities: ['focused-concentration', 'mentor-spirit'],
272
+ // Rating 3 (p.74, 4 karma a rating): the pure spellslinger holds a
273
+ // drain-neutral Force 3 working free -- Increase Reflexes, a barrier.
274
+ // The ledger above priced rating 1; each rating past it is 4 more.
275
+ qualityRatings: { 'focused-concentration': 3 },
266
276
  gearSlugs: [
267
277
  // Was Combat + Detection + Health category formulae (2,000 + 500
268
278
  // + 500). The Detection one was for a category this engine has no
@@ -294,6 +304,9 @@ export const ARCHETYPES = [
294
304
  // The spirits like them, which is not the same as the spirits being
295
305
  // safe. Ledger 25 - 11 = 14 -> 7 karma + 14,000 nuyen.
296
306
  qualities: ['spirit-affinity', 'focused-concentration'],
307
+ // Rating 2 (p.74, 4 karma a rating): a Force 2 working held free.
308
+ // The ledger above priced rating 1; each rating past it is 4 more.
309
+ qualityRatings: { 'focused-concentration': 2 },
297
310
  gearSlugs: [
298
311
  'spirit-focus-1', 'power-focus-1', 'lodge-materials-3',
299
312
  'reagents', 'transys-avalon', 'medkit-6',
@@ -360,6 +373,10 @@ export const ARCHETYPES = [
360
373
  // taxman never stops collecting. Ledger 25 - 5 + 15 = 35 -> 7 karma
361
374
  // + 20,000 nuyen (the rest went into that resonance).
362
375
  qualities: ['analytical-mind', 'natural-hardening', 'prime-datahaven-membership', 'focused-concentration', 'sinner-corp'],
376
+ // Rating 2 (p.74, 4 karma a rating): a Level 2 complex form held
377
+ // free -- the quality covers a form exactly as it covers a spell.
378
+ // The ledger above priced rating 1; each rating past it is 4 more.
379
+ qualityRatings: { 'focused-concentration': 2 },
363
380
  gearSlugs: [
364
381
  'armor-jacket', 'combat-boots', 'fake-sin-4', 'meta-link',
365
382
  'microtransceiver', 'data-tap', 'micro-camera', 'stealth-tags',
@@ -426,7 +443,10 @@ export function archetypeLedger(archetype) {
426
443
  const augNuyen = (archetype.augs ?? []).reduce((n, key) => n + (augFor(key)?.cost ?? 0), 0);
427
444
  const spent = (archetype.qualities ?? []).reduce((n, key) => {
428
445
  const q = getQuality(key);
429
- return n + (q ? (q.kind === 'positive' ? q.karma : -q.karma) : 0);
446
+ if (!q)
447
+ return n;
448
+ const cost = qualityKarma(q, archetype.qualityRatings?.[key]);
449
+ return n + (q.kind === 'positive' ? cost : -cost);
430
450
  }, 0) + (archetype.combat.powerPoints ?? 0) * 5;
431
451
  const karmaRemainder = CREATION_KARMA - spent;
432
452
  const karmaConverted = Math.min(Math.max(0, karmaRemainder - KARMA_CARRYOVER_MAX), KARMA_TO_NUYEN_MAX);
@@ -523,7 +543,7 @@ export function outfitArchetype(player, archetype) {
523
543
  player.picks.set(key, pick);
524
544
  }
525
545
  for (const key of archetype.qualities ?? []) {
526
- player.addQuality(key);
546
+ player.addQuality(key, archetype.qualityRatings?.[key]);
527
547
  }
528
548
  // Power Points ride the SAME pool (SR5 p.69-70, 5 karma each): a mystic
529
549
  // adept archetype pays for its points exactly as a web-built one does.
@@ -531,6 +551,7 @@ export function outfitArchetype(player, archetype) {
531
551
  // exactly as before.
532
552
  const split = creationSplit({
533
553
  qualities: archetype.qualities ?? [],
554
+ qualityRatings: archetype.qualityRatings,
534
555
  powerPoints: archetype.combat.powerPoints ?? 0,
535
556
  });
536
557
  player.karma += split.karma;
@@ -1,3 +1,4 @@
1
+ import { sustainBurden } from '../utilities/sustaining.js';
1
2
  import { BypassCommand } from './bypass-command.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { CombatExchange } from '../utilities/combat-exchange.js';
@@ -776,15 +777,20 @@ export class CastCommand extends BypassCommand {
776
777
  return error ?? `You don't see them here.`;
777
778
  const self = target === this.actor;
778
779
  // Sustained toggle per bearer: casting at them again unravels it, no
779
- // drain to let go. (The caster is the only one who can release it --
780
- // a mundane bearer has no way to drop a spell themselves.)
780
+ // drain to let go. The caster is the only one who can release it
781
+ // (p.282: the caster sustains) -- a weave somebody ELSE is holding
782
+ // on them is that caster's to drop, not yours to overwrite.
781
783
  if (target.sustainedArmor > 0) {
784
+ const holder = target.wornSpellCaster('armor');
785
+ if (holder && holder !== this.actor) {
786
+ return `${holder.name} is holding an armor weave on ${self ? 'you' : target.name} already -- only ${holder.name} can let it go.`;
787
+ }
782
788
  target.setSustainedArmor(0);
783
789
  this.scene.updateStatus();
784
790
  this.actor.performAction('releases armor spell', self ? '' : `on ${target.name}`);
785
791
  return this.finishSpell([self
786
792
  ? `You let the armor spell go. The air stops humming, and the sustaining penalty lifts.`
787
- : `You unravel the armor weave around ${target.name}. The shimmer fades, and their sustaining burden lifts.`]);
793
+ : `You unravel the armor weave around ${target.name}. The shimmer fades, and the burden of holding it lifts from you.`]);
788
794
  }
789
795
  if (target.isIncapacitated()) {
790
796
  return `${target.name} is down -- armor won't help now.`;
@@ -794,16 +800,20 @@ export class CastCommand extends BypassCommand {
794
800
  const focus = this.actor.getBestFocus();
795
801
  const bonus = Math.ceil(force / 3) + (focus?.ratingValue ?? 0); // the focus's Force (catalog v13)
796
802
  const spell = spellFor('armor');
797
- // The weave taxes its BEARER: they get the soak and carry the -2
798
- // sustaining burden. The caster pays only the drain.
803
+ // The BEARER wears the weave (the soak is theirs); the CASTER
804
+ // sustains it and carries the -2 (p.282), on top of the drain.
799
805
  target.setSustainedArmor(bonus);
800
806
  target.noteSustainedForce('armor', force);
807
+ target.sustainedBy('armor', this.actor);
801
808
  this.actor.performAction('casts armor', self ? '' : `on ${target.name}`);
802
809
  this.scene.addWorldEvent(`${this.actor.name} wrapped ${self ? 'themselves' : target.name} in a shimmering armor spell.`);
803
810
  this.scene.updateStatus();
811
+ // The burden is the CASTER's ledger's, not a restated -2: a Focused
812
+ // Concentration holder is told "no penalty" the moment it applies.
813
+ const burden = sustainBurden(this.actor.sustainedLedger().find(w => w.key === 'armor' && (self ? w.bearer === undefined : w.bearer === target.name)), ' to everything else you do');
804
814
  const lines = [
805
815
  `${this.actor.name} casts armor at Force ${force}${self ? '' : ` on ${target.name}`}, ${this.castingLabel()}:`,
806
- ` The air hardens around ${self ? 'you' : target.name} -- +${bonus} soak dice while sustained, -2 dice to everything else ${self ? 'you do' : 'they do'}.`,
816
+ ` The air hardens around ${self ? 'you' : target.name} -- +${bonus} soak dice while sustained${self ? '' : '; you hold the weave up'}, ${burden}.`,
807
817
  hint(` (Cast armor${self ? '' : ` on ${target.name}`} again to release it.)`),
808
818
  ...this.applyDrain(spell, force, overcast),
809
819
  ];
@@ -864,11 +874,12 @@ export class CastCommand extends BypassCommand {
864
874
  ? `${actor.name} wove a mana barrier across the way ${direction} in ${room.name}.`
865
875
  : `${actor.name} raised a shimmering force barrier across the way ${direction} in ${room.name}.`);
866
876
  this.scene.updateStatus();
877
+ const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === spellKey), ' to everything else you do');
867
878
  const lines = [
868
879
  `${actor.name} casts ${mana ? 'mana barrier' : 'barrier'} at Force ${force}, ${this.castingLabel()}:`,
869
880
  mana
870
- ? ` A lattice of woven mana settles across the way ${direction} -- invisible to meat eyes, Force ${force}. SPIRITS and astral forms stop at it; the living walk through without noticing. -2 dice to everything else you do.`
871
- : ` The air itself hardens across the way ${direction} -- a faint heat-shimmer wall, Force ${force}. NOBODY passes while you hold it (you included), and -2 dice to everything else you do.`,
881
+ ? ` A lattice of woven mana settles across the way ${direction} -- invisible to meat eyes, Force ${force}. SPIRITS and astral forms stop at it; the living walk through without noticing. ${burden.charAt(0).toUpperCase()}${burden.slice(1)}.`
882
+ : ` The air itself hardens across the way ${direction} -- a faint heat-shimmer wall, Force ${force}. NOBODY passes while you hold it (you included), and ${burden}.`,
872
883
  hint(` (Cast ${spellKey} again to release it; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
873
884
  ...this.applyDrain(spell, force, overcast),
874
885
  ];
@@ -877,8 +888,8 @@ export class CastCommand extends BypassCommand {
877
888
  }
878
889
  /**
879
890
  * The sustained utility pair (invisibility / increase reflexes), same
880
- * toggle shape as the armor spell: the BEARER carries the effect and
881
- * its -2 sustaining burden, the caster pays the drain, and casting at
891
+ * toggle shape as the armor spell: the BEARER wears the effect, the
892
+ * CASTER sustains it (-2, p.282) and pays the drain, and casting at
882
893
  * the same bearer again releases it for free.
883
894
  */
884
895
  async castSustainedUtility(spellKey, force, overcast, rest = []) {
@@ -889,12 +900,16 @@ export class CastCommand extends BypassCommand {
889
900
  const flag = spellKey === 'invisibility' ? 'sustainedInvisibility' : 'sustainedReflexes';
890
901
  const label = spellKey === 'invisibility' ? 'invisibility' : 'increase reflexes';
891
902
  if (target[flag]) {
903
+ const holder = target.wornSpellCaster(spellKey);
904
+ if (holder && holder !== this.actor) {
905
+ return `${holder.name} is holding ${label} on ${self ? 'you' : target.name} already -- only ${holder.name} can let it go.`;
906
+ }
892
907
  target[flag] = false;
893
908
  this.scene.updateStatus();
894
909
  this.actor.performAction(`releases ${label}`, self ? '' : `on ${target.name}`);
895
910
  return this.finishSpell([spellKey === 'invisibility'
896
- ? `${self ? 'You shimmer back into the world' : `${target.name} shimmers back into the world`} -- the light remembers ${self ? 'you' : 'them'}, and the sustaining burden lifts.`
897
- : `${self ? 'Your' : `${target.name}'s`} nerves cool back to human speed. The sustaining burden lifts.`]);
911
+ ? `${self ? 'You shimmer back into the world' : `${target.name} shimmers back into the world`} -- the light remembers ${self ? 'you' : 'them'}, and the sustaining burden lifts${self ? '' : ' from you'}.`
912
+ : `${self ? 'Your' : `${target.name}'s`} nerves cool back to human speed. The sustaining burden lifts${self ? '' : ' from you'}.`]);
898
913
  }
899
914
  if (target.isIncapacitated()) {
900
915
  return `${target.name} is down -- ${label} won't help now.`;
@@ -906,14 +921,18 @@ export class CastCommand extends BypassCommand {
906
921
  const spell = spellFor(spellKey);
907
922
  target[flag] = true;
908
923
  target.noteSustainedForce(spellKey, force);
924
+ target.sustainedBy(spellKey, this.actor);
909
925
  this.actor.performAction(`casts ${label}`, self ? '' : `on ${target.name}`);
910
926
  this.scene.addWorldEvent(spellKey === 'invisibility'
911
927
  ? `${this.actor.name} wove an invisibility spell${self ? '' : ` over ${target.name}`}.`
912
928
  : `${this.actor.name} cast increase reflexes${self ? '' : ` on ${target.name}`}.`);
913
929
  this.scene.updateStatus();
930
+ // The CASTER's ledger: they hold it, they pay (p.282).
931
+ const burden = sustainBurden(this.actor.sustainedLedger().find(w => w.key === spellKey && (self ? w.bearer === undefined : w.bearer === target.name)), ' to everything else you do');
932
+ const holding = self ? '' : '; you hold it up';
914
933
  const effectLine = spellKey === 'invisibility'
915
- ? ` The light slides around ${self ? 'you' : target.name} like water around a stone -- +3 dice on sneak contests while sustained, -2 dice to everything else ${self ? 'you do' : 'they do'}. Loud acts still announce ${self ? 'you' : 'them'}.`
916
- : ` ${self ? 'Your' : `${target.name}'s`} nerves run HOT -- +2 initiative while sustained, -2 dice to everything else ${self ? 'you do' : 'they do'}.`;
934
+ ? ` The light slides around ${self ? 'you' : target.name} like water around a stone -- +3 dice on sneak contests while sustained${holding}, ${burden}. Loud acts still announce ${self ? 'you' : 'them'}.`
935
+ : ` ${self ? 'Your' : `${target.name}'s`} nerves run HOT -- +2 initiative while sustained${holding}, ${burden}.`;
917
936
  const lines = [
918
937
  `${this.actor.name} casts ${label} at Force ${force}${self ? '' : ` on ${target.name}`}, ${this.castingLabel()}:`,
919
938
  effectLine,
@@ -3,11 +3,13 @@ import { hint } from '../utilities/hints.js';
3
3
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
4
4
  import { catalogMetamagics } from '../utilities/catalog.js';
5
5
  // THE PICKS ARE CATALOG ROWS (kind 'metamagic' on maka-cli.com), read
6
- // through catalogMetamagics(); the shape is ../metamagics.ts. The five
7
- // metamagics whose rule is not expressible as dice (quickening,
8
- // overclocking, the two Matrix upgrades, power-point) are keyed by name
9
- // in models/player.ts -- a row with a new key does nothing until the
10
- // engine grows a seat for it.
6
+ // through catalogMetamagics(); the shape is ../metamagics.ts. The four
7
+ // metamagics whose rule is not expressible as dice (overclocking, the
8
+ // two Matrix upgrades, power-point) are keyed by name in
9
+ // models/player.ts -- a row with a new key does nothing until the
10
+ // engine grows a seat for it. Quickening left that list on 2026-09-06:
11
+ // its row carries a `sustain` effect (utilities/effects.ts) and the
12
+ // engine reads that, not the slug.
11
13
  export class InitiateCommand extends Command {
12
14
  static verb = 'initiate';
13
15
  static description = 'Initiation/submersion at your hideout (10 + grade x3 karma): each grade lifts Magic/Resonance past 6 and grants a metamagic (or echo). "initiate" lists; "initiate <pick>" pays.';
@@ -19,7 +19,7 @@ const onSale = () => catalogQualities().filter(q => !q.retired);
19
19
  */
20
20
  export class QualityTradeCommand extends Command {
21
21
  static verb = 'qualities';
22
- static description = 'Trade qualities with karma at your hideout (SR5e x2 rates): "qualities" lists, "qualities <name>" buys a positive or buys off a negative.';
22
+ static description = 'Trade qualities with karma at your hideout (SR5e x2 rates): "qualities" lists, "qualities <name>" buys a positive or buys off a negative; "qualities <name> <rating>" buys or raises a rated one.';
23
23
  async execute(args = []) {
24
24
  const actor = this.actor;
25
25
  if (actor.inExchange) {
@@ -34,10 +34,19 @@ export class QualityTradeCommand extends Command {
34
34
  if (!args || args.length === 0) {
35
35
  return this.listBoard();
36
36
  }
37
+ // A trailing number is the RATING wanted for a rated quality
38
+ // ("qualities focused concentration 3"); everything before it names
39
+ // the quality. Parsed off first so the name match never sees it.
40
+ let words = [...args];
41
+ let wanted;
42
+ if (words.length > 1 && /^\d+$/.test(words[words.length - 1])) {
43
+ wanted = parseInt(words[words.length - 1], 10);
44
+ words = words.slice(0, -1);
45
+ }
37
46
  // Exact key first ("qualities sinner-criminal" works verbatim), then
38
47
  // fuzzy over display names ONLY -- names and keys together made every
39
48
  // single-word quality ambiguous with its own key.
40
- const input = args.join(' ');
49
+ const input = words.join(' ');
41
50
  const byKey = onSale().find(q => q.key === input.toLowerCase().replace(/\s+/g, '-'));
42
51
  const picked = byKey?.name ?? fuzzyPickName(input, onSale().map(q => q.name));
43
52
  const quality = onSale().find(q => q.name === picked);
@@ -45,30 +54,55 @@ export class QualityTradeCommand extends Command {
45
54
  return `That's not a quality this street knows. "qualities" lists the board.`;
46
55
  }
47
56
  if (quality.kind === 'positive') {
48
- return this.buyPositive(quality);
57
+ return this.buyPositive(quality, wanted);
49
58
  }
50
59
  return this.buyOffNegative(quality);
51
60
  }
52
- buyPositive(quality) {
61
+ /**
62
+ * A RATED quality (IQuality.maxRating) is bought at a rating and
63
+ * priced per rating (p.74 Focused Concentration: 4 karma a rating, x2
64
+ * in play), and a holder may RAISE one by paying for the ratings they
65
+ * do not yet have. Until 2026-09-06 this verb bought every quality at
66
+ * rating 1 and refused to touch it again, so Focused Concentration
67
+ * bought here covered a Force 1 spell for the rest of the character's
68
+ * life.
69
+ */
70
+ buyPositive(quality, wanted) {
53
71
  const actor = this.actor;
54
- if (actor.hasQuality(quality.key)) {
55
- return `${quality.name} is already part of who you are.`;
72
+ const held = actor.hasQuality(quality.key) ? actor.qualityRating(quality.key) : 0;
73
+ if (!quality.maxRating) {
74
+ if (held > 0)
75
+ return `${quality.name} is already part of who you are.`;
76
+ if (wanted !== undefined)
77
+ return `${quality.name} takes no rating -- "qualities ${quality.key}" alone buys it.`;
78
+ }
79
+ const rating = quality.maxRating ? Math.max(1, Math.floor(wanted ?? (held > 0 ? held + 1 : 1))) : 1;
80
+ if (quality.maxRating && rating > quality.maxRating) {
81
+ return `${quality.name} tops out at rating ${quality.maxRating}${hint(` (${quality.page})`)}.`;
82
+ }
83
+ if (quality.maxRating && rating <= held) {
84
+ return `You already hold ${quality.name} at rating ${held}${hint(` -- "qualities ${quality.key} ${held + 1}" raises it`)}.`;
56
85
  }
57
86
  // RAW forbids the one stacking this catalog can produce: High Pain
58
87
  // Tolerance never combines with a pain editor (p.74).
59
88
  if (quality.key === 'high-pain-tolerance' && actor.hasAug('pain-editor')) {
60
89
  return `The pain editor already owns that circuit -- High Pain Tolerance can't grow where chrome filters first${hint(' (SR5e p.74)')}.`;
61
90
  }
62
- const cost = quality.karma * 2;
91
+ // Per rating, for the ratings not yet held: raising 2 -> 4 pays for
92
+ // two, not four.
93
+ const cost = quality.karma * (rating - held) * 2;
94
+ const label = quality.maxRating ? `${quality.name} ${rating}` : quality.name;
63
95
  if (actor.karma < cost) {
64
- return `${quality.name} costs ${cost} karma in play${hint(` (listed ${quality.karma} x2, ${quality.page})`)} -- you have ${actor.karma}.`;
96
+ return `${label} costs ${cost} karma in play${hint(` (listed ${quality.karma}${quality.maxRating ? ' a rating' : ''} x2, ${quality.page})`)} -- you have ${actor.karma}.`;
65
97
  }
66
98
  actor.karma -= cost;
67
- actor.addQuality(quality.key);
68
- this.actor.performAction('grows into', quality.name);
69
- this.logger.write(`${actor.name} bought quality ${quality.key} (${cost} karma, ${actor.karma} left).`);
99
+ actor.addQuality(quality.key, rating);
100
+ this.actor.performAction(held > 0 ? 'deepens' : 'grows into', label);
101
+ this.logger.write(`${actor.name} bought quality ${quality.key}${quality.maxRating ? ` at rating ${rating}` : ''} (${cost} karma, ${actor.karma} left).`);
70
102
  this.scene.updateStatus();
71
- return `It took the downtime to make it true, but it's true now: ${quality.name}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`;
103
+ return held > 0
104
+ ? `The discipline deepens: ${label}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`
105
+ : `It took the downtime to make it true, but it's true now: ${label}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`;
72
106
  }
73
107
  buyOffNegative(quality) {
74
108
  const actor = this.actor;
@@ -106,10 +140,14 @@ export class QualityTradeCommand extends Command {
106
140
  if (owned.length > 0) {
107
141
  lines.push(`Yours:`);
108
142
  for (const q of owned) {
143
+ const held = actor.qualityRating(q.key);
109
144
  const trade = q.kind === 'negative'
110
145
  ? ` buy off for ${q.karma * 2} karma${actor.karma >= q.karma * 2 ? '' : ' (short)'}`
111
- : ` (yours to keep)`;
112
- lines.push(` ${(q.kind === 'positive' ? '+ ' : '- ') + q.name.padEnd(24)} ${q.effect}${trade}`);
146
+ : q.maxRating && held < q.maxRating
147
+ ? ` (rating ${held} of ${q.maxRating} -- ${q.karma * 2} karma a rating to raise)`
148
+ : ` (yours to keep)`;
149
+ const name = q.maxRating ? `${q.name} ${held}` : q.name;
150
+ lines.push(` ${(q.kind === 'positive' ? '+ ' : '- ') + name.padEnd(24)} ${q.effect}${trade}`);
113
151
  }
114
152
  }
115
153
  const buyable = onSale().filter(q => q.kind === 'positive' && !actor.hasQuality(q.key));
@@ -117,7 +155,7 @@ export class QualityTradeCommand extends Command {
117
155
  lines.push(`On the board${hint(' (listed x2 to learn in play)')}:`);
118
156
  for (const q of buyable) {
119
157
  const blocked = q.key === 'high-pain-tolerance' && actor.hasAug('pain-editor');
120
- lines.push(` + ${q.name.padEnd(24)} ${q.effect} -- ${q.karma * 2} karma${blocked ? ' (BLOCKED: pain editor)' : actor.karma >= q.karma * 2 ? '' : ' (short)'}`);
158
+ lines.push(` + ${q.name.padEnd(24)} ${q.effect} -- ${q.karma * 2} karma${q.maxRating ? ` a rating (to ${q.maxRating})` : ''}${blocked ? ' (BLOCKED: pain editor)' : actor.karma >= q.karma * 2 ? '' : ' (short)'}`);
121
159
  }
122
160
  }
123
161
  const footer = hint(`"qualities <name>" buys a positive or buys off a negative. Negatives can't be taken on for karma -- the street hands those out for free (see your sheet's Stains).`);
@@ -1,3 +1,4 @@
1
+ import { sustainBurden } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { homeGrid } from '../utilities/grids.js';
3
4
  import { getAspect } from '../aspects.js';
@@ -172,7 +173,7 @@ export class SheetCommand extends Command {
172
173
  ...(a.initiationGrade > 0 ? [row(a.isTechnomancer() ? 'Submersion' : 'Initiation', `grade ${a.initiationGrade} -- ${catalogMetamagics().filter(m => a.hasMetamagic(m.key)).map(m => m.name).join(', ')}`)] : []),
173
174
  ...(a.lockedFatigue > 0 ? [row('Fatigue', `${a.lockedFatigue} stun LOCKED until the need is met`)] : []),
174
175
  ...(a.woundModifier < 0 ? [row('Wounds', `${a.woundModifier} dice on everything`)] : []),
175
- ...(a.sustainedArmor > 0 ? [row('Sustained', `armor +${a.sustainedArmor} soak (-2 dice)`)] : []),
176
+ ...(a.sustainedArmor > 0 ? [row('Sustained', `armor +${a.sustainedArmor} soak (${a.wornSpellCaster('armor') ? `held by ${a.wornSpellCaster('armor').name}` : sustainBurden(a.sustainedLedger().find(w => w.key === 'armor' && w.bearer === undefined))})`)] : []),
176
177
  ];
177
178
  return lines.join('\n');
178
179
  }
@@ -1,3 +1,4 @@
1
+ import { sustainBurden } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { Category } from '../types/shared/item-enum.js';
3
4
  import { hint } from '../utilities/hints.js';
@@ -66,20 +67,36 @@ export class SpellsCommand extends Command {
66
67
  // The sustained STATE is data; the release coaching is a hint
67
68
  // (player ruling: the spellbook's reference facts stay, its
68
69
  // tutorials go behind the hints toggle).
70
+ // The cost per working is the engine's (Player.sustainedLedger): a
71
+ // Focused Concentration holder reads "no penalty" here, not a
72
+ // restated -2.
73
+ // The CASTER pays (p.282): a spell somebody else holds on you shows
74
+ // as worn, with the holder named; what you hold on others is listed
75
+ // after, with its bearer.
76
+ const ledger = actor.sustainedLedger();
77
+ const burden = (key) => {
78
+ const holder = actor.wornSpellCaster(key);
79
+ return holder ? `held by ${holder.name} -- their burden` : sustainBurden(ledger.find(w => w.key === key && w.bearer === undefined), ' to everything else');
80
+ };
69
81
  if (actor.sustainedArmor > 0) {
70
- lines.push(`Sustained: armor spell, +${actor.sustainedArmor} soak (-2 dice to everything else).${hint(' Cast armor again to release.')}`);
82
+ lines.push(`Sustained: armor spell, +${actor.sustainedArmor} soak (${burden('armor')}).${hint(' Cast armor again to release.')}`);
71
83
  }
72
84
  if (actor.sustainedInvisibility) {
73
- lines.push(`Sustained: invisibility, +3 on sneak contests (-2 dice to everything else).${hint(' Cast invisibility again to release.')}`);
85
+ lines.push(`Sustained: invisibility, +3 on sneak contests (${burden('invisibility')}).${hint(' Cast invisibility again to release.')}`);
86
+ }
87
+ for (const w of ledger) {
88
+ if (w.bearer === undefined)
89
+ continue;
90
+ lines.push(`Sustaining on ${w.bearer}: ${w.key === 'reflexes' ? 'increase reflexes' : w.key} (${sustainBurden(w, ' to everything else')}).${hint(` Cast ${w.key} on ${w.bearer} again to release.`)}`);
74
91
  }
75
92
  if (actor.sustainedBarrier) {
76
- lines.push(`Sustained: force barrier sealing ${actor.sustainedBarrier.direction} in ${actor.sustainedBarrier.roomName}, Force ${actor.sustainedBarrier.force} (-2 dice to everything else).${hint(' Cast barrier again to release.')}`);
93
+ lines.push(`Sustained: force barrier sealing ${actor.sustainedBarrier.direction} in ${actor.sustainedBarrier.roomName}, Force ${actor.sustainedBarrier.force} (${burden('barrier')}).${hint(' Cast barrier again to release.')}`);
77
94
  }
78
95
  if (actor.sustainedManaBarrier) {
79
- lines.push(`Sustained: mana barrier sealing ${actor.sustainedManaBarrier.direction} in ${actor.sustainedManaBarrier.roomName}, Force ${actor.sustainedManaBarrier.force} (-2 dice to everything else).${hint(' Cast manabarrier again to release.')}`);
96
+ lines.push(`Sustained: mana barrier sealing ${actor.sustainedManaBarrier.direction} in ${actor.sustainedManaBarrier.roomName}, Force ${actor.sustainedManaBarrier.force} (${burden('manabarrier')}).${hint(' Cast manabarrier again to release.')}`);
80
97
  }
81
98
  if (actor.sustainedReflexes) {
82
- lines.push(`Sustained: increase reflexes, +2 initiative (-2 dice to everything else).${hint(' Cast it again to release.')}`);
99
+ lines.push(`Sustained: increase reflexes, +2 initiative (${burden('reflexes')}).${hint(' Cast it again to release.')}`);
83
100
  }
84
101
  // The spells themselves -- only what this actor actually knows.
85
102
  // Grouped RAW-style (p.283-293): combat's direct/indirect split is
@@ -1,3 +1,4 @@
1
+ import { sustainTotal } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
@@ -93,14 +94,17 @@ export class SustainCommand extends Command {
93
94
  // "Machine Sprite (Level 1) (Level 1)". Caught on a bench --
94
95
  // the kind of thing that reads as a rendering bug and gets
95
96
  // the whole item bounced.
96
- lines.push(` ${h.npc.name} holds ${h.npc.sustainedForms.map(f => f.key).join(', ')} -- ${h.npc.sustainedForms.length * 2} dice off THEIR pools, none off yours.`);
97
+ lines.push(` ${h.npc.name} holds ${h.npc.sustainedForms.map(f => f.key).join(', ')} -- ${h.npc.sustainedLedger().filter(w => w.kind === 'form').reduce((n, w) => n + w.cost, 0)} dice off THEIR pools, none off yours.`);
97
98
  }
98
99
  }
99
100
  if (mine.length === 0 && lines.length === 0) {
100
101
  return `You hold no complex forms, and nothing of yours is holding one for you.${hint(` ("thread <form>" weaves one.)`)}`;
101
102
  }
103
+ // The cost is the engine's (Player.sustainedLedger): Focused
104
+ // Concentration covers a complex form exactly as it covers a spell
105
+ // (p.74), and this line says so rather than restating -2.
102
106
  const yours = mine.length > 0
103
- ? `You are sustaining ${mine.map(f => f.key).join(', ')} yourself -- ${mine.length * 2} dice off everything you do.`
107
+ ? `You are sustaining ${mine.map(f => f.key).join(', ')} yourself -- ${sustainTotal(actor.sustainedLedger().filter(w => w.kind === 'form'), ' off everything you do')}.`
104
108
  : `You are sustaining nothing yourself.`;
105
109
  const offer = mine.length > 0 && sprites.length > 0
106
110
  ? hint(`\n("sustain <form>" hands one to a registered sprite for a task.)`)
@@ -1,3 +1,4 @@
1
+ import { sustainBurden, sustainTotal } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { matrixConstructsInReach } from '../utilities/matrix-roster.js';
3
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
@@ -69,7 +70,7 @@ export class ThreadCommand extends Command {
69
70
  lines.push(` ${f.name.padEnd(16)} ${f.kind === 'sustained' ? '[sustained]' : '[instant] '} ${f.blurb}${held ? ' {cyan-fg}(HELD){/cyan-fg}' : ''}`);
70
71
  }
71
72
  if (actor.sustainedForms.length > 0) {
72
- lines.push('', ` Holding ${actor.sustainedForms.length} form${actor.sustainedForms.length === 1 ? '' : 's'} (-${actor.sustainedForms.length * 2} dice).${hint(` ("thread drop <form>" releases.)`)}`);
73
+ lines.push('', ` Holding ${actor.sustainedForms.length} form${actor.sustainedForms.length === 1 ? '' : 's'} (${sustainTotal(actor.sustainedLedger().filter(w => w.kind === 'form'))}).${hint(` ("thread drop <form>" releases.)`)}`);
73
74
  }
74
75
  return lines.join('\n');
75
76
  }
@@ -310,7 +311,8 @@ export class ThreadCommand extends Command {
310
311
  // how ambitious it was and not how well it went.
311
312
  actor.sustainedForms.push({ key: form.key, level, hits });
312
313
  const effect = form.key === 'diagnostics' ? '+2 to your Matrix pools' : '+2 Matrix defense';
313
- return [` ${form.name} settles over your persona and HOLDS -- ${effect} while you sustain it (-2 dice to everything else).${hint(` ("thread drop ${form.key}" releases.)`)}`];
314
+ const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === form.key), ' to everything else');
315
+ return [` ${form.name} settles over your persona and HOLDS -- ${effect} while you sustain it (${burden}).${hint(` ("thread drop ${form.key}" releases.)`)}`];
314
316
  }
315
317
  default:
316
318
  return [` The form resolves and fades.`];
@@ -1,3 +1,4 @@
1
+ import { sustainBurden, sustainTotal } from './utilities/sustaining.js';
1
2
  import { agentRatingFromName } from './utilities/programs.js';
2
3
  import { MATRIX_STANCE_ACTION } from './models/player.js';
3
4
  import { buildDossierUrl } from './utilities/web-character-generator.js';
@@ -5276,8 +5277,20 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5276
5277
  lines.push(`${armorNote}${arSlot} | ${link} | ${docwagon}`);
5277
5278
  // Lines 4+: transient states, present only while active. Future
5278
5279
  // contexts (astral projection, VR hot-sim) join here the same way.
5280
+ // THE COST IS THE ENGINE'S. Every one of these lines printed a
5281
+ // literal -2 until 2026-09-06, while sustainingPenalty was already
5282
+ // waiving a Focused Concentration holder's spell -- the HUD told the
5283
+ // player they were paying for what the pools were not charging.
5284
+ //
5285
+ // THE CASTER PAYS (p.282, 2026-09-06). A spell somebody else holds
5286
+ // on this character is on their sheet as WORN -- soak and all --
5287
+ // with the holder named and no cost; what this character holds on
5288
+ // others rides the ledger below with its bearer named.
5289
+ const ledger = player.sustainedLedger();
5290
+ const heldBy = (key) => player.wornSpellCaster(key)?.name;
5279
5291
  if (player.sustainedArmor > 0) {
5280
- lines.push(`{magenta-fg}Sustained: armor +${player.sustainedArmor} soak (-2 dice to everything else){/magenta-fg}`);
5292
+ const holder = heldBy('armor');
5293
+ lines.push(`{magenta-fg}Sustained: armor +${player.sustainedArmor} soak (${holder ? `held by ${holder} -- their burden` : sustainBurden(ledger.find(w => w.key === 'armor' && w.bearer === undefined), ' to everything else')}){/magenta-fg}`);
5281
5294
  }
5282
5295
  // THE MAGE'S HALF OF THE SAME QUESTION (TNf9WrZGxCmCzqA4L, scope
5283
5296
  // completed by ruling).
@@ -5303,10 +5316,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5303
5316
  // Naming it belongs with the condition monitors if it belongs
5304
5317
  // anywhere; that is a separate call and not this item's.
5305
5318
  const heldSpells = [];
5319
+ const worn = (label, key) => heldSpells.push(heldBy(key) ? `${label} (held by ${heldBy(key)})` : label);
5306
5320
  if (player.sustainedInvisibility)
5307
- heldSpells.push('invisibility');
5321
+ worn('invisibility', 'invisibility');
5308
5322
  if (player.sustainedReflexes)
5309
- heldSpells.push('increased reflexes');
5323
+ worn('increased reflexes', 'reflexes');
5310
5324
  // The barriers say WHERE, because a wall you forgot you put up is
5311
5325
  // the one that strands the team -- and both of them seal an exit
5312
5326
  // that the caster cannot pass either.
@@ -5316,8 +5330,15 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5316
5330
  if (player.sustainedManaBarrier) {
5317
5331
  heldSpells.push(`mana barrier (${player.sustainedManaBarrier.roomName} ${player.sustainedManaBarrier.direction})`);
5318
5332
  }
5333
+ // What this character holds on OTHERS -- the caster's burden, named
5334
+ // by bearer so the mage knows whose armor is costing them two dice.
5335
+ for (const w of ledger) {
5336
+ if (w.bearer !== undefined)
5337
+ heldSpells.push(`${w.key === 'reflexes' ? 'increased reflexes' : w.key} on ${w.bearer}`);
5338
+ }
5319
5339
  if (heldSpells.length > 0) {
5320
- lines.push(`{magenta-fg}✴ Sustained: ${heldSpells.join(', ')} (-${heldSpells.length * 2} dice){/magenta-fg}`);
5340
+ const these = ledger.filter(w => w.kind === 'spell' && !(w.key === 'armor' && w.bearer === undefined));
5341
+ lines.push(`{magenta-fg}✴ Sustained: ${heldSpells.join(', ')} (${sustainTotal(these)}){/magenta-fg}`);
5321
5342
  }
5322
5343
  // ACTIVE THREADS, WITHOUT ASKING (TNf9WrZGxCmCzqA4L: "instead of
5323
5344
  // saying 'sustain' to see the active threads, list the threads in
@@ -5348,7 +5369,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5348
5369
  : [];
5349
5370
  if (ownThreads.length > 0 || spriteHeld.length > 0) {
5350
5371
  const mine = ownThreads.length > 0
5351
- ? `${ownThreads.map(f => f.key).join(', ')} (-${ownThreads.length * 2} dice)`
5372
+ ? `${ownThreads.map(f => f.key).join(', ')} (${sustainTotal(ledger.filter(w => w.kind === 'form'))})`
5352
5373
  : `none yourself`;
5353
5374
  const carried = spriteHeld.length > 0 ? ` | ${spriteHeld.join('; ')}` : '';
5354
5375
  lines.push(`{magenta-fg}✴ Threads: ${mine}${carried}{/magenta-fg}`);
@@ -824,8 +824,8 @@ Available in-game commands (use with [COMMAND] prefix):
824
824
  "manabolt" (combat damage resisted by body, not armor), "heal [on <ally>]" (heal
825
825
  physical wounds -- your own by default, or an ally's in your room; healing a
826
826
  wounded ally is a strong support move), "armor [on <ally>]" (sustained protection
827
- on yourself or an ally; its bearer gets +soak at -2 dice to all else; cast at them
828
- again to release). EVERY cast costs drain -- stun damage to YOU the caster; fill
827
+ on yourself or an ally; its bearer gets +soak, and YOU carry -2 dice to all else
828
+ while you hold it; cast at them again to release). EVERY cast costs drain -- stun damage to YOU the caster; fill
829
829
  your stun track and you're out cold. Never cast if you're mundane with no magic gear.
830
830
  - cast — bare "cast" attempts to magically bypass the ward/barrier in this room
831
831
  without knowing the answer (requires the same talent or gear; failing burns the
@@ -3630,6 +3630,8 @@ export class Player extends AbstractPlayer {
3630
3630
  if (next === this._sustainedArmor)
3631
3631
  return;
3632
3632
  this._sustainedArmor = next;
3633
+ if (next === 0)
3634
+ this.onSustainDropped('armor');
3633
3635
  // SUSTAINS BEAT ON FLIP (web report 2026-09-02: "I cast armor on myself
3634
3636
  // and don't see it in the sustained list"): the sheet only caught up on
3635
3637
  // the next unrelated beat. Every sustain mutator fires the hook now.
@@ -3658,29 +3660,130 @@ export class Player extends AbstractPlayer {
3658
3660
  * holding nothing.
3659
3661
  */
3660
3662
  sustainedNames() {
3663
+ return this.sustainedWorkings().map(w => w.key);
3664
+ }
3665
+ /**
3666
+ * EVERY WORKING THIS CHARACTER IS HOLDING UP, in one shape (2026-09-06).
3667
+ *
3668
+ * The sustaining rule (p.282: -2 dice per spell or complex form held)
3669
+ * and its waivers (Focused Concentration p.74, Quickening p.326, a
3670
+ * sprite's task p.256) were written against five named spell slots
3671
+ * and a forms array, one branch each -- so a technomancer's Focused
3672
+ * Concentration, which canon says covers a complex form exactly as it
3673
+ * covers a spell, covered nothing, because forms were summed on a
3674
+ * separate line. One list, composed at read time from the fields
3675
+ * cast.ts and thread.ts already keep (the same way sustainedNames and
3676
+ * sustainedMagnitudes read them), is what lets the rule be ONE rule.
3677
+ *
3678
+ * `force` is the Force a spell was cast at or the Level a form was
3679
+ * threaded at -- the number a waiver's ceiling is measured against.
3680
+ * A spell whose Force nobody recorded is `undefined`, and ineligible
3681
+ * (honest, not generous). Barriers carry it in their seat; the other
3682
+ * spells in `sustainedForces`.
3683
+ */
3684
+ sustainedWorkings() {
3685
+ const mine = this.wornWorkings().filter(w => w.caster === undefined).map(({ caster: _c, ...w }) => w);
3686
+ const onOthers = [];
3687
+ for (const bearer of this.sustainedBearers) {
3688
+ const held = bearer.wornWorkings().filter(w => w.caster === this);
3689
+ if (held.length === 0) {
3690
+ this.sustainedBearers.delete(bearer);
3691
+ continue;
3692
+ }
3693
+ for (const { caster: _c, ...w } of held)
3694
+ onOthers.push({ ...w, bearer: bearer.name });
3695
+ }
3696
+ return [...mine, ...onOthers];
3697
+ }
3698
+ /**
3699
+ * Every working whose STATE is on this character, with who holds it
3700
+ * up when that is somebody else. The bearer's view; sustainedWorkings
3701
+ * is the holder's.
3702
+ */
3703
+ wornWorkings() {
3704
+ const spell = (key, force) => ({
3705
+ key, kind: 'spell', force, category: spellFor(key)?.category.toLowerCase(), caster: this.sustainedCasters.get(key),
3706
+ });
3661
3707
  return [
3662
- ...this.sustainedForms.map(f => f.key),
3663
- ...(this._sustainedArmor > 0 ? ['armor'] : []),
3664
- ...(this.sustainedInvisibility ? ['invisibility'] : []),
3665
- ...(this.sustainedReflexes ? ['reflexes'] : []),
3666
- ...(this.sustainedBarrier ? ['barrier'] : []),
3667
- ...(this.sustainedManaBarrier ? ['manabarrier'] : []),
3708
+ ...this.sustainedForms.map((f) => ({ key: f.key, kind: 'form', force: f.level })),
3709
+ ...(this._sustainedArmor > 0 ? [spell('armor', this.sustainedForces.get('armor'))] : []),
3710
+ ...(this.sustainedInvisibility ? [spell('invisibility', this.sustainedForces.get('invisibility'))] : []),
3711
+ ...(this.sustainedReflexes ? [spell('reflexes', this.sustainedForces.get('reflexes'))] : []),
3712
+ ...(this.sustainedBarrier ? [spell('barrier', this.sustainedBarrier.force)] : []),
3713
+ ...(this.sustainedManaBarrier ? [spell('manabarrier', this.sustainedManaBarrier.force)] : []),
3668
3714
  ];
3669
3715
  }
3716
+ /**
3717
+ * The workings with what each one COSTS, and who is paying it off.
3718
+ *
3719
+ * A `sustain` effect (utilities/effects.ts) from any lane -- a quality
3720
+ * held, a metamagic learned, one day a focus carried -- holds one
3721
+ * working free. Waivers are matched most-specific first (an anchored
3722
+ * slug, then a narrowed one, then a general one, lowest ceiling
3723
+ * first), and each takes the highest-Force working it can cover: the
3724
+ * general waiver must not spend itself on the spell the specific one
3725
+ * was for. Every working costs the same two dice, so the assignment
3726
+ * that leaves the fewest unwaived is the one that pays least.
3727
+ */
3728
+ sustainedLedger() {
3729
+ const workings = this.sustainedWorkings();
3730
+ const waivers = this.applyingEffects()
3731
+ .flatMap(a => a.effect.type === 'sustain' ? [{ effect: a.effect, rating: a.rating, lane: a.lane, slug: a.slug }] : [])
3732
+ .map(w => ({ ...w, ceiling: w.effect.property === 'anchored' ? Infinity : w.effect.value * scaleOf(w.effect, w.rating) }))
3733
+ .sort((a, b) => {
3734
+ const rank = (w) => w.effect.property === 'anchored' ? 0 : w.effect.only !== undefined ? 1 : 2;
3735
+ return rank(a) - rank(b) || a.ceiling - b.ceiling;
3736
+ });
3737
+ const waivedBy = new Map();
3738
+ for (const w of waivers) {
3739
+ const covers = (x) => {
3740
+ if (w.effect.property === 'anchored')
3741
+ return w.effect.only !== undefined && w.effect.only === x.key;
3742
+ if (x.force === undefined)
3743
+ return false;
3744
+ if (w.effect.only !== undefined && w.effect.only !== x.key && w.effect.only !== x.category)
3745
+ return false;
3746
+ return x.force <= w.ceiling;
3747
+ };
3748
+ let pick = -1;
3749
+ workings.forEach((x, i) => {
3750
+ if (waivedBy.has(i) || !covers(x))
3751
+ return;
3752
+ if (pick < 0 || (x.force ?? 0) > (workings[pick].force ?? 0))
3753
+ pick = i;
3754
+ });
3755
+ if (pick >= 0)
3756
+ waivedBy.set(pick, this.effectSourceName(w.lane, w.slug));
3757
+ }
3758
+ return workings.map((x, i) => ({ ...x, cost: waivedBy.has(i) ? 0 : 2, waivedBy: waivedBy.get(i) }));
3759
+ }
3760
+ /** The row an applying effect came from, in the player's words. */
3761
+ effectSourceName(lane, slug) {
3762
+ switch (lane) {
3763
+ case 'quality': return getQuality(slug)?.name ?? slug;
3764
+ case 'initiate': return metamagicFor(slug)?.name ?? slug;
3765
+ case 'adept': return adeptPowerFor(slug)?.name ?? slug;
3766
+ case 'chrome': return augFor(slug)?.name ?? slug;
3767
+ default: return slug;
3768
+ }
3769
+ }
3670
3770
  releaseSustainedWorkings() {
3671
- const count = this.sustainedForms.length
3672
- + (this._sustainedArmor > 0 ? 1 : 0)
3673
- + (this.sustainedInvisibility ? 1 : 0)
3674
- + (this.sustainedReflexes ? 1 : 0)
3675
- + (this.sustainedBarrier ? 1 : 0)
3676
- + (this.sustainedManaBarrier ? 1 : 0);
3677
- this.sustainedForms = [];
3678
- this._sustainedArmor = 0;
3679
- this.sustainedInvisibility = false;
3680
- this.sustainedReflexes = false;
3681
- this.sustainedBarrier = undefined;
3682
- this.sustainedManaBarrier = undefined;
3683
- return count;
3771
+ // What this character HOLDS -- their own, and what they hold on
3772
+ // others (which ends on the bearer). A spell somebody else holds on
3773
+ // this character is that caster's concentration, and sleeping in
3774
+ // it does not break it.
3775
+ const held = this.sustainedWorkings();
3776
+ for (const w of held) {
3777
+ if (w.bearer === undefined) {
3778
+ this.dropSustainedWorking(w.key);
3779
+ continue;
3780
+ }
3781
+ for (const bearer of this.sustainedBearers) {
3782
+ if (bearer.name === w.bearer)
3783
+ bearer.dropSustainedWorking(w.key);
3784
+ }
3785
+ }
3786
+ return held.length;
3684
3787
  }
3685
3788
  // The other sustained spells (RAW gear/spell pass): invisibility bends
3686
3789
  // light around the bearer (+3 on sneak contests, npc.ts), increase
@@ -3692,6 +3795,8 @@ export class Player extends AbstractPlayer {
3692
3795
  if (this._sustainedInvisibility === v)
3693
3796
  return;
3694
3797
  this._sustainedInvisibility = v;
3798
+ if (!v)
3799
+ this.onSustainDropped('invisibility');
3695
3800
  this.noteConditionChanged();
3696
3801
  }
3697
3802
  _sustainedReflexes = false;
@@ -3700,8 +3805,73 @@ export class Player extends AbstractPlayer {
3700
3805
  if (this._sustainedReflexes === v)
3701
3806
  return;
3702
3807
  this._sustainedReflexes = v;
3808
+ if (!v)
3809
+ this.onSustainDropped('reflexes');
3703
3810
  this.noteConditionChanged();
3704
3811
  }
3812
+ /**
3813
+ * WHO HOLDS A SPELL THAT IS ON THIS CHARACTER, when it is not them
3814
+ * (2026-09-06: "bill the caster, not the bearer"). SR5 p.282 puts the
3815
+ * -2 on whoever SUSTAINS the spell; the subject only wears it. Until
3816
+ * this the engine taxed the bearer -- a mundane street sam with an
3817
+ * ally's armor on them lost two dice off every pool, and the mage
3818
+ * holding it up paid nothing.
3819
+ *
3820
+ * The effect state stays on the bearer (that is what grants the
3821
+ * soak); this map is the other half, and the caster's ledger reads
3822
+ * it through `sustainedBearers`. Session-only, like every sustain.
3823
+ */
3824
+ sustainedCasters = new Map();
3825
+ /** The characters this one holds a spell on. Pruned at read time. */
3826
+ sustainedBearers = new Set();
3827
+ /** Record that `caster` is the one holding `key` up on this character. */
3828
+ sustainedBy(key, caster) {
3829
+ if (caster === this) {
3830
+ this.sustainedCasters.delete(key);
3831
+ return;
3832
+ }
3833
+ this.sustainedCasters.set(key, caster);
3834
+ caster.sustainedBearers.add(this);
3835
+ caster.noteConditionChanged();
3836
+ }
3837
+ /** The caster holding `key` on this character, when it is somebody else. */
3838
+ wornSpellCaster(key) {
3839
+ return this.sustainedCasters.get(key);
3840
+ }
3841
+ /** Bookkeeping when a spell on this character ends, whoever ended it. */
3842
+ onSustainDropped(key) {
3843
+ this.sustainedForces.delete(key);
3844
+ const caster = this.sustainedCasters.get(key);
3845
+ if (caster) {
3846
+ this.sustainedCasters.delete(key);
3847
+ caster.noteConditionChanged();
3848
+ }
3849
+ }
3850
+ /** End one working by key, whatever kind it is. */
3851
+ dropSustainedWorking(key) {
3852
+ switch (key) {
3853
+ case 'armor':
3854
+ this.setSustainedArmor(0);
3855
+ return;
3856
+ case 'invisibility':
3857
+ this.sustainedInvisibility = false;
3858
+ return;
3859
+ case 'reflexes':
3860
+ this.sustainedReflexes = false;
3861
+ return;
3862
+ case 'barrier':
3863
+ this.sustainedBarrier = undefined;
3864
+ return;
3865
+ case 'manabarrier':
3866
+ this.sustainedManaBarrier = undefined;
3867
+ return;
3868
+ default: {
3869
+ const i = this.sustainedForms.findIndex(f => f.key === key);
3870
+ if (i >= 0)
3871
+ this.sustainedForms.splice(i, 1);
3872
+ }
3873
+ }
3874
+ }
3705
3875
  /** The sustained PHYSICAL BARRIER spell (ruling 2026-08-24): seals
3706
3876
  * one exit of one room while held -- go.ts and move refuse passage
3707
3877
  * for everyone (the caster included; walls don't play favorites).
@@ -3712,6 +3882,8 @@ export class Player extends AbstractPlayer {
3712
3882
  if (this._sustainedBarrier === v)
3713
3883
  return;
3714
3884
  this._sustainedBarrier = v;
3885
+ if (!v)
3886
+ this.onSustainDropped('barrier');
3715
3887
  this.noteConditionChanged();
3716
3888
  }
3717
3889
  /** The MANA BARRIER twin (canon p.294, RAG-checked): blocks SPIRITS
@@ -3723,6 +3895,8 @@ export class Player extends AbstractPlayer {
3723
3895
  if (this._sustainedManaBarrier === v)
3724
3896
  return;
3725
3897
  this._sustainedManaBarrier = v;
3898
+ if (!v)
3899
+ this.onSustainDropped('manabarrier');
3726
3900
  this.noteConditionChanged();
3727
3901
  }
3728
3902
  // THREADED COMPLEX FORMS (M4.15, see commands/thread.ts): the
@@ -3764,7 +3938,12 @@ export class Player extends AbstractPlayer {
3764
3938
  get formDefenseBonus() {
3765
3939
  return this.sustainedForms.some(f => f.key === 'static-veil') ? 2 : 0;
3766
3940
  }
3767
- /** The Force each sustained spell was cast at (cast.ts records it) -- Focused Concentration reads it. */
3941
+ /**
3942
+ * The Force each sustained spell was cast at, by spell slug (cast.ts
3943
+ * records it; sustainedWorkings reads it). Cleared by the setter that
3944
+ * drops the spell, so a recast at a new Force is judged fresh rather
3945
+ * than against the number the last cast left behind.
3946
+ */
3768
3947
  sustainedForces = new Map();
3769
3948
  noteSustainedForce(kind, force) {
3770
3949
  this.sustainedForces.set(kind, force);
@@ -3774,32 +3953,16 @@ export class Player extends AbstractPlayer {
3774
3953
  return this.isTechnomancer() ? 0 : this.qualityRating('gremlins');
3775
3954
  }
3776
3955
  get sustainingPenalty() {
3777
- // Each sustained effect costs -2 dice across the board: the armor
3778
- // spell, invisibility, hot reflexes, and open astral senses (see
3779
- // commands/assense.ts). They stack -- holding two is walking around
3780
- // half-blind. Dumpshock disorientation (p.229) rides the same rail.
3781
- // QUICKENING (metamagic, p.326 adapted): the armor spell anchors into
3782
- // the world and sustains itself -- its -2 simply never bills.
3783
- const armorCost = this._sustainedArmor > 0 && !this.hasMetamagic('quickening') ? 2 : 0;
3784
- // FOCUSED CONCENTRATION (p.74, RAG-checked 2026-09-02): "sustain one
3785
- // spell with a Force equal to or less than your rating without
3786
- // penalty". One spell, whose Force the cast recorded; a spell with
3787
- // no recorded Force is not eligible (honest, not generous).
3788
- const spells = [
3789
- { cost: armorCost, force: this.sustainedForces.get('armor') },
3790
- { cost: this.sustainedInvisibility ? 2 : 0, force: this.sustainedForces.get('invisibility') },
3791
- { cost: this.sustainedReflexes ? 2 : 0, force: this.sustainedForces.get('reflexes') },
3792
- { cost: this.sustainedBarrier ? 2 : 0, force: this.sustainedBarrier?.force },
3793
- { cost: this.sustainedManaBarrier ? 2 : 0, force: this.sustainedManaBarrier?.force },
3794
- ];
3795
- const focus = this.qualityRating('focused-concentration');
3796
- const waived = focus > 0 ? spells.find(sp => sp.cost > 0 && sp.force !== undefined && sp.force <= focus) : undefined;
3797
- if (waived)
3798
- waived.cost = 0;
3799
- return spells.reduce((n, sp) => n + sp.cost, 0)
3956
+ // Each working held costs -2 dice across the board (p.282): a
3957
+ // sustained spell, a threaded complex form. They stack -- holding
3958
+ // two is walking around half-blind. Which ones are held FREE is the
3959
+ // catalog's to say, through `sustain` effects (Focused Concentration
3960
+ // p.74, Quickening p.326 adapted); the engine names no row here.
3961
+ // Open astral senses (commands/assense.ts) and dumpshock
3962
+ // disorientation (p.229) ride the same rail.
3963
+ return this.sustainedLedger().reduce((n, w) => n + w.cost, 0)
3800
3964
  + (this.astralPerceiving ? 2 : 0)
3801
- + (this.dumpshocked ? 2 : 0)
3802
- + this.sustainedForms.length * 2;
3965
+ + (this.dumpshocked ? 2 : 0);
3803
3966
  }
3804
3967
  // Astral perception (see commands/assense.ts): senses held OPEN as a
3805
3968
  // sustained state -- passive aura reads, at -2 dice to everything else.
@@ -105,7 +105,18 @@ export function parseCatalogVersion(raw) {
105
105
  const n = Number(raw);
106
106
  return Number.isFinite(n) ? n : undefined;
107
107
  }
108
+ /**
109
+ * Set by resetCatalogForTest and never cleared: a suite that has reset
110
+ * the catalog wants ONLY what it applies. Found 2026-09-06, the day the
111
+ * developer's cache moved to v17 -- every fixture pinned at 16 lost the
112
+ * "highest version wins" contest to a file in the home directory, and
113
+ * eleven accessor tests went red on one machine and green on every
114
+ * other. Exactly the failure the note above says a test must not have.
115
+ */
116
+ let diskCacheDisabled = false;
108
117
  function readCache() {
118
+ if (diskCacheDisabled)
119
+ return undefined;
109
120
  try {
110
121
  const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
111
122
  if (Array.isArray(parsed?.items) && parsed.items.length > 0)
@@ -571,6 +582,10 @@ export function resetCatalogForTest() {
571
582
  liveSource = undefined;
572
583
  resolved = undefined;
573
584
  fetchAttempted = false;
585
+ // The machine's cache is out of the contest from here on: a fixture
586
+ // applied after this is the whole catalog, whatever version it says.
587
+ // A test that wants a cache passes its own reader (CacheReader).
588
+ diskCacheDisabled = true;
574
589
  resetCatalogIndexes();
575
590
  }
576
591
  /**
@@ -697,6 +712,11 @@ export function describeEffect(e) {
697
712
  case 'cap': return e.property === 'chosen'
698
713
  ? `${sign}${e.value} to the maximum of one chosen ${e.category === 'skills' ? 'skill' : 'attribute'}${only}`
699
714
  : `${sign}${e.value} to the ${word} maximum${only}`;
715
+ // A waiver reads as what it holds FREE, not as dice: one entry, one
716
+ // working, and the ceiling it is measured against.
717
+ case 'sustain': return e.property === 'anchored'
718
+ ? `${e.only ? e.only.replace(/-/g, ' ') : 'a spell'} sustains itself -- no sustaining penalty`
719
+ : `sustain one ${e.only ? `${e.only.replace(/-/g, ' ')} ` : ''}spell or complex form up to Force ${e.value}${scale} with no penalty`;
700
720
  }
701
721
  }
702
722
  /** Fuzzy lookup for source.ts: exact slug, exact name, then loose
@@ -0,0 +1,36 @@
1
+ /**
2
+ * THE SUSTAINING COST, IN THE PLAYER'S WORDS -- read from the engine's
3
+ * ledger, never restated (2026-09-06).
4
+ *
5
+ * Every line that reported a sustained working used to print a literal
6
+ * "-2 dice": the HUD, the cast echo, the spellbook, the sheet, the
7
+ * thread list. Player.sustainingPenalty was already waiving the spell a
8
+ * Focused Concentration holder was owed, and every one of those lines
9
+ * went on telling them they were paying for it. A number the engine
10
+ * computes is printed from the engine, or it is a lie waiting for the
11
+ * next rule.
12
+ */
13
+ /** "-2 dice to everything else" or "no penalty -- Focused Concentration". */
14
+ export function sustainBurden(entry, tail = '') {
15
+ if (!entry)
16
+ return `no penalty`;
17
+ if (entry.cost > 0)
18
+ return `-${entry.cost} dice${tail}`;
19
+ return entry.waivedBy ? `no penalty -- ${entry.waivedBy}` : `no penalty`;
20
+ }
21
+ /**
22
+ * The total for a group of workings: "-4 dice", or "no penalty" when
23
+ * every one is held free, or "-2 dice, invisibility free (Focused
24
+ * Concentration)" when only some are.
25
+ */
26
+ export function sustainTotal(entries, tail = '') {
27
+ const total = entries.reduce((n, e) => n + e.cost, 0);
28
+ const free = entries.filter(e => e.cost === 0);
29
+ if (total === 0) {
30
+ const by = [...new Set(free.map(e => e.waivedBy).filter((n) => !!n))];
31
+ return by.length > 0 ? `no penalty -- ${by.join(', ')}` : `no penalty`;
32
+ }
33
+ const notes = free.map(e => `${e.key} free${e.waivedBy ? ` (${e.waivedBy})` : ''}`);
34
+ return `-${total} dice${tail}${notes.length > 0 ? `, ${notes.join(', ')}` : ''}`;
35
+ }
36
+ //# sourceMappingURL=sustaining.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.128.0",
3
+ "version": "5.130.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",