@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +24 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +33 -14
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +7 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js +53 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +22 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/sustain.js +6 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +4 -2
- package/bundle/typescript/src/commands/game/sideQuest/game.js +26 -5
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +208 -45
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/sustaining.js +36 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
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
|
-
|
|
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.
|
|
780
|
-
//
|
|
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
|
|
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
|
|
798
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
881
|
-
*
|
|
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,
|
|
916
|
-
: ` ${self ? 'Your' : `${target.name}'s`} nerves run HOT -- +2 initiative while sustained,
|
|
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
|
|
7
|
-
// metamagics whose rule is not expressible as dice (
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 `${
|
|
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',
|
|
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
|
|
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
|
-
:
|
|
112
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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} (
|
|
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} (
|
|
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 (
|
|
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.
|
|
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 -- ${
|
|
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'} (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5321
|
+
worn('invisibility', 'invisibility');
|
|
5308
5322
|
if (player.sustainedReflexes)
|
|
5309
|
-
|
|
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
|
-
|
|
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(', ')} (
|
|
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
|
|
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
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
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
|
-
/**
|
|
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
|
|
3778
|
-
// spell,
|
|
3779
|
-
//
|
|
3780
|
-
//
|
|
3781
|
-
//
|
|
3782
|
-
//
|
|
3783
|
-
|
|
3784
|
-
|
|
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.
|
|
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.",
|