@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.128.0",
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
- return n + (q ? (q.kind === 'positive' ? q.karma : -q.karma) : 0);
446
+ if (!q)
447
+ return n;
448
+ const cost = qualityKarma(q, archetype.qualityRatings?.[key]);
449
+ return n + (q.kind === 'positive' ? cost : -cost);
430
450
  }, 0) + (archetype.combat.powerPoints ?? 0) * 5;
431
451
  const karmaRemainder = CREATION_KARMA - spent;
432
452
  const karmaConverted = Math.min(Math.max(0, karmaRemainder - KARMA_CARRYOVER_MAX), KARMA_TO_NUYEN_MAX);
@@ -523,7 +543,7 @@ export function outfitArchetype(player, archetype) {
523
543
  player.picks.set(key, pick);
524
544
  }
525
545
  for (const key of archetype.qualities ?? []) {
526
- player.addQuality(key);
546
+ player.addQuality(key, archetype.qualityRatings?.[key]);
527
547
  }
528
548
  // Power Points ride the SAME pool (SR5 p.69-70, 5 karma each): a mystic
529
549
  // adept archetype pays for its points exactly as a web-built one does.
@@ -531,6 +551,7 @@ export function outfitArchetype(player, archetype) {
531
551
  // exactly as before.
532
552
  const split = creationSplit({
533
553
  qualities: archetype.qualities ?? [],
554
+ qualityRatings: archetype.qualityRatings,
534
555
  powerPoints: archetype.combat.powerPoints ?? 0,
535
556
  });
536
557
  player.karma += split.karma;
@@ -1,3 +1,4 @@
1
+ import { sustainBurden } from '../utilities/sustaining.js';
1
2
  import { BypassCommand } from './bypass-command.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { CombatExchange } from '../utilities/combat-exchange.js';
@@ -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, -2 dice to everything else ${self ? 'you do' : 'they do'}.`,
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. -2 dice to everything else you do.`
871
- : ` The air itself hardens across the way ${direction} -- a faint heat-shimmer wall, Force ${force}. NOBODY passes while you hold it (you included), and -2 dice to everything else you do.`,
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, -2 dice to everything else ${self ? 'you do' : 'they do'}. Loud acts still announce ${self ? 'you' : 'them'}.`
916
- : ` ${self ? 'Your' : `${target.name}'s`} nerves run HOT -- +2 initiative while sustained, -2 dice to everything else ${self ? 'you do' : 'they do'}.`;
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 five
7
- // metamagics whose rule is not expressible as dice (quickening,
8
- // overclocking, the two Matrix upgrades, power-point) are keyed by name
9
- // in models/player.ts -- a row with a new key does nothing until the
10
- // engine grows a seat for it.
6
+ // through catalogMetamagics(); the shape is ../metamagics.ts. The four
7
+ // metamagics whose rule is not expressible as dice (overclocking, the
8
+ // two Matrix upgrades, power-point) are keyed by name in
9
+ // models/player.ts -- a row with a new key does nothing until the
10
+ // engine grows a seat for it. Quickening left that list on 2026-09-06:
11
+ // its row carries a `sustain` effect (utilities/effects.ts) and the
12
+ // engine reads that, not the slug.
11
13
  export class InitiateCommand extends Command {
12
14
  static verb = 'initiate';
13
15
  static description = 'Initiation/submersion at your hideout (10 + grade x3 karma): each grade lifts Magic/Resonance past 6 and grants a metamagic (or echo). "initiate" lists; "initiate <pick>" pays.';
@@ -19,7 +19,7 @@ const onSale = () => catalogQualities().filter(q => !q.retired);
19
19
  */
20
20
  export class QualityTradeCommand extends Command {
21
21
  static verb = 'qualities';
22
- static description = 'Trade qualities with karma at your hideout (SR5e x2 rates): "qualities" lists, "qualities <name>" buys a positive or buys off a negative.';
22
+ static description = 'Trade qualities with karma at your hideout (SR5e x2 rates): "qualities" lists, "qualities <name>" buys a positive or buys off a negative; "qualities <name> <rating>" buys or raises a rated one.';
23
23
  async execute(args = []) {
24
24
  const actor = this.actor;
25
25
  if (actor.inExchange) {
@@ -34,10 +34,19 @@ export class QualityTradeCommand extends Command {
34
34
  if (!args || args.length === 0) {
35
35
  return this.listBoard();
36
36
  }
37
+ // A trailing number is the RATING wanted for a rated quality
38
+ // ("qualities focused concentration 3"); everything before it names
39
+ // the quality. Parsed off first so the name match never sees it.
40
+ let words = [...args];
41
+ let wanted;
42
+ if (words.length > 1 && /^\d+$/.test(words[words.length - 1])) {
43
+ wanted = parseInt(words[words.length - 1], 10);
44
+ words = words.slice(0, -1);
45
+ }
37
46
  // Exact key first ("qualities sinner-criminal" works verbatim), then
38
47
  // fuzzy over display names ONLY -- names and keys together made every
39
48
  // single-word quality ambiguous with its own key.
40
- const input = args.join(' ');
49
+ const input = words.join(' ');
41
50
  const byKey = onSale().find(q => q.key === input.toLowerCase().replace(/\s+/g, '-'));
42
51
  const picked = byKey?.name ?? fuzzyPickName(input, onSale().map(q => q.name));
43
52
  const quality = onSale().find(q => q.name === picked);
@@ -45,30 +54,55 @@ export class QualityTradeCommand extends Command {
45
54
  return `That's not a quality this street knows. "qualities" lists the board.`;
46
55
  }
47
56
  if (quality.kind === 'positive') {
48
- return this.buyPositive(quality);
57
+ return this.buyPositive(quality, wanted);
49
58
  }
50
59
  return this.buyOffNegative(quality);
51
60
  }
52
- buyPositive(quality) {
61
+ /**
62
+ * A RATED quality (IQuality.maxRating) is bought at a rating and
63
+ * priced per rating (p.74 Focused Concentration: 4 karma a rating, x2
64
+ * in play), and a holder may RAISE one by paying for the ratings they
65
+ * do not yet have. Until 2026-09-06 this verb bought every quality at
66
+ * rating 1 and refused to touch it again, so Focused Concentration
67
+ * bought here covered a Force 1 spell for the rest of the character's
68
+ * life.
69
+ */
70
+ buyPositive(quality, wanted) {
53
71
  const actor = this.actor;
54
- if (actor.hasQuality(quality.key)) {
55
- return `${quality.name} is already part of who you are.`;
72
+ const held = actor.hasQuality(quality.key) ? actor.qualityRating(quality.key) : 0;
73
+ if (!quality.maxRating) {
74
+ if (held > 0)
75
+ return `${quality.name} is already part of who you are.`;
76
+ if (wanted !== undefined)
77
+ return `${quality.name} takes no rating -- "qualities ${quality.key}" alone buys it.`;
78
+ }
79
+ const rating = quality.maxRating ? Math.max(1, Math.floor(wanted ?? (held > 0 ? held + 1 : 1))) : 1;
80
+ if (quality.maxRating && rating > quality.maxRating) {
81
+ return `${quality.name} tops out at rating ${quality.maxRating}${hint(` (${quality.page})`)}.`;
82
+ }
83
+ if (quality.maxRating && rating <= held) {
84
+ return `You already hold ${quality.name} at rating ${held}${hint(` -- "qualities ${quality.key} ${held + 1}" raises it`)}.`;
56
85
  }
57
86
  // RAW forbids the one stacking this catalog can produce: High Pain
58
87
  // Tolerance never combines with a pain editor (p.74).
59
88
  if (quality.key === 'high-pain-tolerance' && actor.hasAug('pain-editor')) {
60
89
  return `The pain editor already owns that circuit -- High Pain Tolerance can't grow where chrome filters first${hint(' (SR5e p.74)')}.`;
61
90
  }
62
- const cost = quality.karma * 2;
91
+ // Per rating, for the ratings not yet held: raising 2 -> 4 pays for
92
+ // two, not four.
93
+ const cost = quality.karma * (rating - held) * 2;
94
+ const label = quality.maxRating ? `${quality.name} ${rating}` : quality.name;
63
95
  if (actor.karma < cost) {
64
- return `${quality.name} costs ${cost} karma in play${hint(` (listed ${quality.karma} x2, ${quality.page})`)} -- you have ${actor.karma}.`;
96
+ return `${label} costs ${cost} karma in play${hint(` (listed ${quality.karma}${quality.maxRating ? ' a rating' : ''} x2, ${quality.page})`)} -- you have ${actor.karma}.`;
65
97
  }
66
98
  actor.karma -= cost;
67
- actor.addQuality(quality.key);
68
- this.actor.performAction('grows into', quality.name);
69
- this.logger.write(`${actor.name} bought quality ${quality.key} (${cost} karma, ${actor.karma} left).`);
99
+ actor.addQuality(quality.key, rating);
100
+ this.actor.performAction(held > 0 ? 'deepens' : 'grows into', label);
101
+ this.logger.write(`${actor.name} bought quality ${quality.key}${quality.maxRating ? ` at rating ${rating}` : ''} (${cost} karma, ${actor.karma} left).`);
70
102
  this.scene.updateStatus();
71
- return `It took the downtime to make it true, but it's true now: ${quality.name}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`;
103
+ return held > 0
104
+ ? `The discipline deepens: ${label}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`
105
+ : `It took the downtime to make it true, but it's true now: ${label}. ${quality.effect}. (${cost} karma spent, ${actor.karma} left.)`;
72
106
  }
73
107
  buyOffNegative(quality) {
74
108
  const actor = this.actor;
@@ -106,10 +140,14 @@ export class QualityTradeCommand extends Command {
106
140
  if (owned.length > 0) {
107
141
  lines.push(`Yours:`);
108
142
  for (const q of owned) {
143
+ const held = actor.qualityRating(q.key);
109
144
  const trade = q.kind === 'negative'
110
145
  ? ` buy off for ${q.karma * 2} karma${actor.karma >= q.karma * 2 ? '' : ' (short)'}`
111
- : ` (yours to keep)`;
112
- lines.push(` ${(q.kind === 'positive' ? '+ ' : '- ') + q.name.padEnd(24)} ${q.effect}${trade}`);
146
+ : q.maxRating && held < q.maxRating
147
+ ? ` (rating ${held} of ${q.maxRating} -- ${q.karma * 2} karma a rating to raise)`
148
+ : ` (yours to keep)`;
149
+ const name = q.maxRating ? `${q.name} ${held}` : q.name;
150
+ lines.push(` ${(q.kind === 'positive' ? '+ ' : '- ') + name.padEnd(24)} ${q.effect}${trade}`);
113
151
  }
114
152
  }
115
153
  const buyable = onSale().filter(q => q.kind === 'positive' && !actor.hasQuality(q.key));
@@ -117,7 +155,7 @@ export class QualityTradeCommand extends Command {
117
155
  lines.push(`On the board${hint(' (listed x2 to learn in play)')}:`);
118
156
  for (const q of buyable) {
119
157
  const blocked = q.key === 'high-pain-tolerance' && actor.hasAug('pain-editor');
120
- lines.push(` + ${q.name.padEnd(24)} ${q.effect} -- ${q.karma * 2} karma${blocked ? ' (BLOCKED: pain editor)' : actor.karma >= q.karma * 2 ? '' : ' (short)'}`);
158
+ lines.push(` + ${q.name.padEnd(24)} ${q.effect} -- ${q.karma * 2} karma${q.maxRating ? ` a rating (to ${q.maxRating})` : ''}${blocked ? ' (BLOCKED: pain editor)' : actor.karma >= q.karma * 2 ? '' : ' (short)'}`);
121
159
  }
122
160
  }
123
161
  const footer = hint(`"qualities <name>" buys a positive or buys off a negative. Negatives can't be taken on for karma -- the street hands those out for free (see your sheet's Stains).`);
@@ -1,3 +1,4 @@
1
+ import { sustainBurden } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { homeGrid } from '../utilities/grids.js';
3
4
  import { getAspect } from '../aspects.js';
@@ -172,7 +173,7 @@ export class SheetCommand extends Command {
172
173
  ...(a.initiationGrade > 0 ? [row(a.isTechnomancer() ? 'Submersion' : 'Initiation', `grade ${a.initiationGrade} -- ${catalogMetamagics().filter(m => a.hasMetamagic(m.key)).map(m => m.name).join(', ')}`)] : []),
173
174
  ...(a.lockedFatigue > 0 ? [row('Fatigue', `${a.lockedFatigue} stun LOCKED until the need is met`)] : []),
174
175
  ...(a.woundModifier < 0 ? [row('Wounds', `${a.woundModifier} dice on everything`)] : []),
175
- ...(a.sustainedArmor > 0 ? [row('Sustained', `armor +${a.sustainedArmor} soak (-2 dice)`)] : []),
176
+ ...(a.sustainedArmor > 0 ? [row('Sustained', `armor +${a.sustainedArmor} soak (${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 (-2 dice to everything else).${hint(' Cast armor again to release.')}`);
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 (-2 dice to everything else).${hint(' Cast invisibility again to release.')}`);
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} (-2 dice to everything else).${hint(' Cast barrier again to release.')}`);
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} (-2 dice to everything else).${hint(' Cast manabarrier again to release.')}`);
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 (-2 dice to everything else).${hint(' Cast it again to release.')}`);
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.sustainedForms.length * 2} dice off THEIR pools, none off yours.`);
97
+ lines.push(` ${h.npc.name} holds ${h.npc.sustainedForms.map(f => f.key).join(', ')} -- ${h.npc.sustainedLedger().filter(w => w.kind === 'form').reduce((n, w) => n + w.cost, 0)} dice off THEIR pools, none off yours.`);
97
98
  }
98
99
  }
99
100
  if (mine.length === 0 && lines.length === 0) {
100
101
  return `You hold no complex forms, and nothing of yours is holding one for you.${hint(` ("thread <form>" weaves one.)`)}`;
101
102
  }
103
+ // The cost is the engine's (Player.sustainedLedger): Focused
104
+ // Concentration covers a complex form exactly as it covers a spell
105
+ // (p.74), and this line says so rather than restating -2.
102
106
  const yours = mine.length > 0
103
- ? `You are sustaining ${mine.map(f => f.key).join(', ')} yourself -- ${mine.length * 2} dice off everything you do.`
107
+ ? `You are sustaining ${mine.map(f => f.key).join(', ')} yourself -- ${sustainTotal(actor.sustainedLedger().filter(w => w.kind === 'form'), ' off everything you do')}.`
104
108
  : `You are sustaining nothing yourself.`;
105
109
  const offer = mine.length > 0 && sprites.length > 0
106
110
  ? hint(`\n("sustain <form>" hands one to a registered sprite for a task.)`)
@@ -1,3 +1,4 @@
1
+ import { sustainBurden, sustainTotal } from '../utilities/sustaining.js';
1
2
  import { Command } from './command.js';
2
3
  import { matrixConstructsInReach } from '../utilities/matrix-roster.js';
3
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
@@ -69,7 +70,7 @@ export class ThreadCommand extends Command {
69
70
  lines.push(` ${f.name.padEnd(16)} ${f.kind === 'sustained' ? '[sustained]' : '[instant] '} ${f.blurb}${held ? ' {cyan-fg}(HELD){/cyan-fg}' : ''}`);
70
71
  }
71
72
  if (actor.sustainedForms.length > 0) {
72
- lines.push('', ` Holding ${actor.sustainedForms.length} form${actor.sustainedForms.length === 1 ? '' : 's'} (-${actor.sustainedForms.length * 2} dice).${hint(` ("thread drop <form>" releases.)`)}`);
73
+ lines.push('', ` Holding ${actor.sustainedForms.length} form${actor.sustainedForms.length === 1 ? '' : 's'} (${sustainTotal(actor.sustainedLedger().filter(w => w.kind === 'form'))}).${hint(` ("thread drop <form>" releases.)`)}`);
73
74
  }
74
75
  return lines.join('\n');
75
76
  }
@@ -310,7 +311,8 @@ export class ThreadCommand extends Command {
310
311
  // how ambitious it was and not how well it went.
311
312
  actor.sustainedForms.push({ key: form.key, level, hits });
312
313
  const effect = form.key === 'diagnostics' ? '+2 to your Matrix pools' : '+2 Matrix defense';
313
- return [` ${form.name} settles over your persona and HOLDS -- ${effect} while you sustain it (-2 dice to everything else).${hint(` ("thread drop ${form.key}" releases.)`)}`];
314
+ const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === form.key), ' to everything else');
315
+ return [` ${form.name} settles over your persona and HOLDS -- ${effect} while you sustain it (${burden}).${hint(` ("thread drop ${form.key}" releases.)`)}`];
314
316
  }
315
317
  default:
316
318
  return [` The form resolves and fades.`];
@@ -1,3 +1,4 @@
1
+ import { sustainBurden, sustainTotal } from './utilities/sustaining.js';
1
2
  import { agentRatingFromName } from './utilities/programs.js';
2
3
  import { MATRIX_STANCE_ACTION } from './models/player.js';
3
4
  import { buildDossierUrl } from './utilities/web-character-generator.js';
@@ -5276,8 +5277,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 (-2 dice to everything else){/magenta-fg}`);
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
- lines.push(`{magenta-fg}✴ Sustained: ${heldSpells.join(', ')} (-${heldSpells.length * 2} dice){/magenta-fg}`);
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(', ')} (-${ownThreads.length * 2} dice)`
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.sustainedForms.length
3672
- + (this._sustainedArmor > 0 ? 1 : 0)
3673
- + (this.sustainedInvisibility ? 1 : 0)
3674
- + (this.sustainedReflexes ? 1 : 0)
3675
- + (this.sustainedBarrier ? 1 : 0)
3676
- + (this.sustainedManaBarrier ? 1 : 0);
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
- /** The Force each sustained spell was cast at (cast.ts records it) -- Focused Concentration reads it. */
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 sustained effect costs -2 dice across the board: the armor
3778
- // spell, invisibility, hot reflexes, and open astral senses (see
3779
- // commands/assense.ts). They stack -- holding two is walking around
3780
- // half-blind. Dumpshock disorientation (p.229) rides the same rail.
3781
- // QUICKENING (metamagic, p.326 adapted): the armor spell anchors into
3782
- // the world and sustains itself -- its -2 simply never bills.
3783
- const armorCost = this._sustainedArmor > 0 && !this.hasMetamagic('quickening') ? 2 : 0;
3784
- // FOCUSED CONCENTRATION (p.74, RAG-checked 2026-09-02): "sustain one
3785
- // spell with a Force equal to or less than your rating without
3786
- // penalty". One spell, whose Force the cast recorded; a spell with
3787
- // no recorded Force is not eligible (honest, not generous).
3788
- const spells = [
3789
- { cost: armorCost, force: this.sustainedForces.get('armor') },
3790
- { cost: this.sustainedInvisibility ? 2 : 0, force: this.sustainedForces.get('invisibility') },
3791
- { cost: this.sustainedReflexes ? 2 : 0, force: this.sustainedForces.get('reflexes') },
3792
- { cost: this.sustainedBarrier ? 2 : 0, force: this.sustainedBarrier?.force },
3793
- { cost: this.sustainedManaBarrier ? 2 : 0, force: this.sustainedManaBarrier?.force },
3794
- ];
3795
- const focus = this.qualityRating('focused-concentration');
3796
- const waived = focus > 0 ? spells.find(sp => sp.cost > 0 && sp.force !== undefined && sp.force <= focus) : undefined;
3797
- if (waived)
3798
- waived.cost = 0;
3799
- return spells.reduce((n, sp) => n + sp.cost, 0)
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.128.0",
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.",