@maka/maka-cli 5.128.0 → 5.129.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 +11 -5
- 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 +11 -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 +10 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +108 -38
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +5 -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.129.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';
|
|
@@ -801,9 +802,12 @@ export class CastCommand extends BypassCommand {
|
|
|
801
802
|
this.actor.performAction('casts armor', self ? '' : `on ${target.name}`);
|
|
802
803
|
this.scene.addWorldEvent(`${this.actor.name} wrapped ${self ? 'themselves' : target.name} in a shimmering armor spell.`);
|
|
803
804
|
this.scene.updateStatus();
|
|
805
|
+
// The burden is the bearer's ledger's, not a restated -2: a Focused
|
|
806
|
+
// Concentration holder is told "no penalty" the moment it applies.
|
|
807
|
+
const burden = sustainBurden(target.sustainedLedger().find(w => w.key === 'armor'), ` to everything else ${self ? 'you do' : 'they do'}`);
|
|
804
808
|
const lines = [
|
|
805
809
|
`${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,
|
|
810
|
+
` The air hardens around ${self ? 'you' : target.name} -- +${bonus} soak dice while sustained, ${burden}.`,
|
|
807
811
|
hint(` (Cast armor${self ? '' : ` on ${target.name}`} again to release it.)`),
|
|
808
812
|
...this.applyDrain(spell, force, overcast),
|
|
809
813
|
];
|
|
@@ -864,11 +868,12 @@ export class CastCommand extends BypassCommand {
|
|
|
864
868
|
? `${actor.name} wove a mana barrier across the way ${direction} in ${room.name}.`
|
|
865
869
|
: `${actor.name} raised a shimmering force barrier across the way ${direction} in ${room.name}.`);
|
|
866
870
|
this.scene.updateStatus();
|
|
871
|
+
const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === spellKey), ' to everything else you do');
|
|
867
872
|
const lines = [
|
|
868
873
|
`${actor.name} casts ${mana ? 'mana barrier' : 'barrier'} at Force ${force}, ${this.castingLabel()}:`,
|
|
869
874
|
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
|
|
875
|
+
? ` 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)}.`
|
|
876
|
+
: ` 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
877
|
hint(` (Cast ${spellKey} again to release it; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
|
|
873
878
|
...this.applyDrain(spell, force, overcast),
|
|
874
879
|
];
|
|
@@ -911,9 +916,10 @@ export class CastCommand extends BypassCommand {
|
|
|
911
916
|
? `${this.actor.name} wove an invisibility spell${self ? '' : ` over ${target.name}`}.`
|
|
912
917
|
: `${this.actor.name} cast increase reflexes${self ? '' : ` on ${target.name}`}.`);
|
|
913
918
|
this.scene.updateStatus();
|
|
919
|
+
const burden = sustainBurden(target.sustainedLedger().find(w => w.key === spellKey), ` to everything else ${self ? 'you do' : 'they do'}`);
|
|
914
920
|
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,
|
|
921
|
+
? ` The light slides around ${self ? 'you' : target.name} like water around a stone -- +3 dice on sneak contests while sustained, ${burden}. Loud acts still announce ${self ? 'you' : 'them'}.`
|
|
922
|
+
: ` ${self ? 'Your' : `${target.name}'s`} nerves run HOT -- +2 initiative while sustained, ${burden}.`;
|
|
917
923
|
const lines = [
|
|
918
924
|
`${this.actor.name} casts ${label} at Force ${force}${self ? '' : ` on ${target.name}`}, ${this.castingLabel()}:`,
|
|
919
925
|
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 (${sustainBurden(a.sustainedLedger().find(w => w.key === 'armor'))})`)] : []),
|
|
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,25 @@ 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
|
+
const ledger = actor.sustainedLedger();
|
|
74
|
+
const burden = (key) => sustainBurden(ledger.find(w => w.key === key), ' to everything else');
|
|
69
75
|
if (actor.sustainedArmor > 0) {
|
|
70
|
-
lines.push(`Sustained: armor spell, +${actor.sustainedArmor} soak (
|
|
76
|
+
lines.push(`Sustained: armor spell, +${actor.sustainedArmor} soak (${burden('armor')}).${hint(' Cast armor again to release.')}`);
|
|
71
77
|
}
|
|
72
78
|
if (actor.sustainedInvisibility) {
|
|
73
|
-
lines.push(`Sustained: invisibility, +3 on sneak contests (
|
|
79
|
+
lines.push(`Sustained: invisibility, +3 on sneak contests (${burden('invisibility')}).${hint(' Cast invisibility again to release.')}`);
|
|
74
80
|
}
|
|
75
81
|
if (actor.sustainedBarrier) {
|
|
76
|
-
lines.push(`Sustained: force barrier sealing ${actor.sustainedBarrier.direction} in ${actor.sustainedBarrier.roomName}, Force ${actor.sustainedBarrier.force} (
|
|
82
|
+
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
83
|
}
|
|
78
84
|
if (actor.sustainedManaBarrier) {
|
|
79
|
-
lines.push(`Sustained: mana barrier sealing ${actor.sustainedManaBarrier.direction} in ${actor.sustainedManaBarrier.roomName}, Force ${actor.sustainedManaBarrier.force} (
|
|
85
|
+
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
86
|
}
|
|
81
87
|
if (actor.sustainedReflexes) {
|
|
82
|
-
lines.push(`Sustained: increase reflexes, +2 initiative (
|
|
88
|
+
lines.push(`Sustained: increase reflexes, +2 initiative (${burden('reflexes')}).${hint(' Cast it again to release.')}`);
|
|
83
89
|
}
|
|
84
90
|
// The spells themselves -- only what this actor actually knows.
|
|
85
91
|
// 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,13 @@ ${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
|
+
const ledger = player.sustainedLedger();
|
|
5279
5285
|
if (player.sustainedArmor > 0) {
|
|
5280
|
-
lines.push(`{magenta-fg}Sustained: armor +${player.sustainedArmor} soak (
|
|
5286
|
+
lines.push(`{magenta-fg}Sustained: armor +${player.sustainedArmor} soak (${sustainBurden(ledger.find(w => w.key === 'armor'), ' to everything else')}){/magenta-fg}`);
|
|
5281
5287
|
}
|
|
5282
5288
|
// THE MAGE'S HALF OF THE SAME QUESTION (TNf9WrZGxCmCzqA4L, scope
|
|
5283
5289
|
// completed by ruling).
|
|
@@ -5317,7 +5323,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5317
5323
|
heldSpells.push(`mana barrier (${player.sustainedManaBarrier.roomName} ${player.sustainedManaBarrier.direction})`);
|
|
5318
5324
|
}
|
|
5319
5325
|
if (heldSpells.length > 0) {
|
|
5320
|
-
|
|
5326
|
+
const these = ledger.filter(w => w.kind === 'spell' && w.key !== 'armor');
|
|
5327
|
+
lines.push(`{magenta-fg}✴ Sustained: ${heldSpells.join(', ')} (${sustainTotal(these)}){/magenta-fg}`);
|
|
5321
5328
|
}
|
|
5322
5329
|
// ACTIVE THREADS, WITHOUT ASKING (TNf9WrZGxCmCzqA4L: "instead of
|
|
5323
5330
|
// saying 'sustain' to see the active threads, list the threads in
|
|
@@ -5348,7 +5355,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5348
5355
|
: [];
|
|
5349
5356
|
if (ownThreads.length > 0 || spriteHeld.length > 0) {
|
|
5350
5357
|
const mine = ownThreads.length > 0
|
|
5351
|
-
? `${ownThreads.map(f => f.key).join(', ')} (
|
|
5358
|
+
? `${ownThreads.map(f => f.key).join(', ')} (${sustainTotal(ledger.filter(w => w.kind === 'form'))})`
|
|
5352
5359
|
: `none yourself`;
|
|
5353
5360
|
const carried = spriteHeld.length > 0 ? ` | ${spriteHeld.join('; ')}` : '';
|
|
5354
5361
|
lines.push(`{magenta-fg}✴ Threads: ${mine}${carried}{/magenta-fg}`);
|
|
@@ -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.sustainedForces.delete('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,28 +3660,103 @@ 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 spell = (key, force) => ({
|
|
3686
|
+
key, kind: 'spell', force, category: spellFor(key)?.category.toLowerCase(),
|
|
3687
|
+
});
|
|
3661
3688
|
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'] : []),
|
|
3689
|
+
...this.sustainedForms.map((f) => ({ key: f.key, kind: 'form', force: f.level })),
|
|
3690
|
+
...(this._sustainedArmor > 0 ? [spell('armor', this.sustainedForces.get('armor'))] : []),
|
|
3691
|
+
...(this.sustainedInvisibility ? [spell('invisibility', this.sustainedForces.get('invisibility'))] : []),
|
|
3692
|
+
...(this.sustainedReflexes ? [spell('reflexes', this.sustainedForces.get('reflexes'))] : []),
|
|
3693
|
+
...(this.sustainedBarrier ? [spell('barrier', this.sustainedBarrier.force)] : []),
|
|
3694
|
+
...(this.sustainedManaBarrier ? [spell('manabarrier', this.sustainedManaBarrier.force)] : []),
|
|
3668
3695
|
];
|
|
3669
3696
|
}
|
|
3697
|
+
/**
|
|
3698
|
+
* The workings with what each one COSTS, and who is paying it off.
|
|
3699
|
+
*
|
|
3700
|
+
* A `sustain` effect (utilities/effects.ts) from any lane -- a quality
|
|
3701
|
+
* held, a metamagic learned, one day a focus carried -- holds one
|
|
3702
|
+
* working free. Waivers are matched most-specific first (an anchored
|
|
3703
|
+
* slug, then a narrowed one, then a general one, lowest ceiling
|
|
3704
|
+
* first), and each takes the highest-Force working it can cover: the
|
|
3705
|
+
* general waiver must not spend itself on the spell the specific one
|
|
3706
|
+
* was for. Every working costs the same two dice, so the assignment
|
|
3707
|
+
* that leaves the fewest unwaived is the one that pays least.
|
|
3708
|
+
*/
|
|
3709
|
+
sustainedLedger() {
|
|
3710
|
+
const workings = this.sustainedWorkings();
|
|
3711
|
+
const waivers = this.applyingEffects()
|
|
3712
|
+
.flatMap(a => a.effect.type === 'sustain' ? [{ effect: a.effect, rating: a.rating, lane: a.lane, slug: a.slug }] : [])
|
|
3713
|
+
.map(w => ({ ...w, ceiling: w.effect.property === 'anchored' ? Infinity : w.effect.value * scaleOf(w.effect, w.rating) }))
|
|
3714
|
+
.sort((a, b) => {
|
|
3715
|
+
const rank = (w) => w.effect.property === 'anchored' ? 0 : w.effect.only !== undefined ? 1 : 2;
|
|
3716
|
+
return rank(a) - rank(b) || a.ceiling - b.ceiling;
|
|
3717
|
+
});
|
|
3718
|
+
const waivedBy = new Map();
|
|
3719
|
+
for (const w of waivers) {
|
|
3720
|
+
const covers = (x) => {
|
|
3721
|
+
if (w.effect.property === 'anchored')
|
|
3722
|
+
return w.effect.only !== undefined && w.effect.only === x.key;
|
|
3723
|
+
if (x.force === undefined)
|
|
3724
|
+
return false;
|
|
3725
|
+
if (w.effect.only !== undefined && w.effect.only !== x.key && w.effect.only !== x.category)
|
|
3726
|
+
return false;
|
|
3727
|
+
return x.force <= w.ceiling;
|
|
3728
|
+
};
|
|
3729
|
+
let pick = -1;
|
|
3730
|
+
workings.forEach((x, i) => {
|
|
3731
|
+
if (waivedBy.has(i) || !covers(x))
|
|
3732
|
+
return;
|
|
3733
|
+
if (pick < 0 || (x.force ?? 0) > (workings[pick].force ?? 0))
|
|
3734
|
+
pick = i;
|
|
3735
|
+
});
|
|
3736
|
+
if (pick >= 0)
|
|
3737
|
+
waivedBy.set(pick, this.effectSourceName(w.lane, w.slug));
|
|
3738
|
+
}
|
|
3739
|
+
return workings.map((x, i) => ({ ...x, cost: waivedBy.has(i) ? 0 : 2, waivedBy: waivedBy.get(i) }));
|
|
3740
|
+
}
|
|
3741
|
+
/** The row an applying effect came from, in the player's words. */
|
|
3742
|
+
effectSourceName(lane, slug) {
|
|
3743
|
+
switch (lane) {
|
|
3744
|
+
case 'quality': return getQuality(slug)?.name ?? slug;
|
|
3745
|
+
case 'initiate': return metamagicFor(slug)?.name ?? slug;
|
|
3746
|
+
case 'adept': return adeptPowerFor(slug)?.name ?? slug;
|
|
3747
|
+
case 'chrome': return augFor(slug)?.name ?? slug;
|
|
3748
|
+
default: return slug;
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3670
3751
|
releaseSustainedWorkings() {
|
|
3671
|
-
const count = this.
|
|
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);
|
|
3752
|
+
const count = this.sustainedWorkings().length;
|
|
3677
3753
|
this.sustainedForms = [];
|
|
3678
3754
|
this._sustainedArmor = 0;
|
|
3679
3755
|
this.sustainedInvisibility = false;
|
|
3680
3756
|
this.sustainedReflexes = false;
|
|
3681
3757
|
this.sustainedBarrier = undefined;
|
|
3682
3758
|
this.sustainedManaBarrier = undefined;
|
|
3759
|
+
this.sustainedForces.clear();
|
|
3683
3760
|
return count;
|
|
3684
3761
|
}
|
|
3685
3762
|
// The other sustained spells (RAW gear/spell pass): invisibility bends
|
|
@@ -3692,6 +3769,8 @@ export class Player extends AbstractPlayer {
|
|
|
3692
3769
|
if (this._sustainedInvisibility === v)
|
|
3693
3770
|
return;
|
|
3694
3771
|
this._sustainedInvisibility = v;
|
|
3772
|
+
if (!v)
|
|
3773
|
+
this.sustainedForces.delete('invisibility');
|
|
3695
3774
|
this.noteConditionChanged();
|
|
3696
3775
|
}
|
|
3697
3776
|
_sustainedReflexes = false;
|
|
@@ -3700,6 +3779,8 @@ export class Player extends AbstractPlayer {
|
|
|
3700
3779
|
if (this._sustainedReflexes === v)
|
|
3701
3780
|
return;
|
|
3702
3781
|
this._sustainedReflexes = v;
|
|
3782
|
+
if (!v)
|
|
3783
|
+
this.sustainedForces.delete('reflexes');
|
|
3703
3784
|
this.noteConditionChanged();
|
|
3704
3785
|
}
|
|
3705
3786
|
/** The sustained PHYSICAL BARRIER spell (ruling 2026-08-24): seals
|
|
@@ -3764,7 +3845,12 @@ export class Player extends AbstractPlayer {
|
|
|
3764
3845
|
get formDefenseBonus() {
|
|
3765
3846
|
return this.sustainedForms.some(f => f.key === 'static-veil') ? 2 : 0;
|
|
3766
3847
|
}
|
|
3767
|
-
/**
|
|
3848
|
+
/**
|
|
3849
|
+
* The Force each sustained spell was cast at, by spell slug (cast.ts
|
|
3850
|
+
* records it; sustainedWorkings reads it). Cleared by the setter that
|
|
3851
|
+
* drops the spell, so a recast at a new Force is judged fresh rather
|
|
3852
|
+
* than against the number the last cast left behind.
|
|
3853
|
+
*/
|
|
3768
3854
|
sustainedForces = new Map();
|
|
3769
3855
|
noteSustainedForce(kind, force) {
|
|
3770
3856
|
this.sustainedForces.set(kind, force);
|
|
@@ -3774,32 +3860,16 @@ export class Player extends AbstractPlayer {
|
|
|
3774
3860
|
return this.isTechnomancer() ? 0 : this.qualityRating('gremlins');
|
|
3775
3861
|
}
|
|
3776
3862
|
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)
|
|
3863
|
+
// Each working held costs -2 dice across the board (p.282): a
|
|
3864
|
+
// sustained spell, a threaded complex form. They stack -- holding
|
|
3865
|
+
// two is walking around half-blind. Which ones are held FREE is the
|
|
3866
|
+
// catalog's to say, through `sustain` effects (Focused Concentration
|
|
3867
|
+
// p.74, Quickening p.326 adapted); the engine names no row here.
|
|
3868
|
+
// Open astral senses (commands/assense.ts) and dumpshock
|
|
3869
|
+
// disorientation (p.229) ride the same rail.
|
|
3870
|
+
return this.sustainedLedger().reduce((n, w) => n + w.cost, 0)
|
|
3800
3871
|
+ (this.astralPerceiving ? 2 : 0)
|
|
3801
|
-
+ (this.dumpshocked ? 2 : 0)
|
|
3802
|
-
+ this.sustainedForms.length * 2;
|
|
3872
|
+
+ (this.dumpshocked ? 2 : 0);
|
|
3803
3873
|
}
|
|
3804
3874
|
// Astral perception (see commands/assense.ts): senses held OPEN as a
|
|
3805
3875
|
// sustained state -- passive aura reads, at -2 dice to everything else.
|
|
@@ -697,6 +697,11 @@ export function describeEffect(e) {
|
|
|
697
697
|
case 'cap': return e.property === 'chosen'
|
|
698
698
|
? `${sign}${e.value} to the maximum of one chosen ${e.category === 'skills' ? 'skill' : 'attribute'}${only}`
|
|
699
699
|
: `${sign}${e.value} to the ${word} maximum${only}`;
|
|
700
|
+
// A waiver reads as what it holds FREE, not as dice: one entry, one
|
|
701
|
+
// working, and the ceiling it is measured against.
|
|
702
|
+
case 'sustain': return e.property === 'anchored'
|
|
703
|
+
? `${e.only ? e.only.replace(/-/g, ' ') : 'a spell'} sustains itself -- no sustaining penalty`
|
|
704
|
+
: `sustain one ${e.only ? `${e.only.replace(/-/g, ' ')} ` : ''}spell or complex form up to Force ${e.value}${scale} with no penalty`;
|
|
700
705
|
}
|
|
701
706
|
}
|
|
702
707
|
/** 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.129.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.",
|