@maka/maka-cli 5.122.0 → 5.123.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/deploy/ubuntu.sub.cmd.js +12 -8
- package/bundle/typescript/src/commands/deploy/ubuntu.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/adept-powers.d.ts +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/adept-powers.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/adept-powers.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.d.ts +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +40 -14
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/augmentations.d.ts +33 -10
- package/bundle/typescript/src/commands/game/sideQuest/augmentations.js +155 -72
- package/bundle/typescript/src/commands/game/sideQuest/augmentations.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +15 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +33 -33
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +6 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +4 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.d.ts +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +7 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +10 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.d.ts +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js +95 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +45 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +119 -8
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/metatypes.d.ts +53 -0
- package/bundle/typescript/src/commands/game/sideQuest/metatypes.js +86 -0
- package/bundle/typescript/src/commands/game/sideQuest/metatypes.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +215 -18
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +548 -117
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +3 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/qualities.d.ts +96 -4
- package/bundle/typescript/src/commands/game/sideQuest/qualities.js +290 -11
- package/bundle/typescript/src/commands/game/sideQuest/qualities.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/skill-groups.d.ts +11 -1
- package/bundle/typescript/src/commands/game/sideQuest/skill-groups.js +13 -1
- package/bundle/typescript/src/commands/game/sideQuest/skill-groups.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/skills.d.ts +17 -0
- package/bundle/typescript/src/commands/game/sideQuest/skills.js +23 -0
- package/bundle/typescript/src/commands/game/sideQuest/skills.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/spells.d.ts +76 -3
- package/bundle/typescript/src/commands/game/sideQuest/spells.js +48 -21
- package/bundle/typescript/src/commands/game/sideQuest/spells.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +90 -82
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +277 -10
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.d.ts +121 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +342 -18
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.d.ts +65 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js +74 -7
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/effects.d.ts +248 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/effects.js +38 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/effects.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/first-aid.js +1 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/first-aid.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js +1 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pregen-worksheet.js +22 -12
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pregen-worksheet.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +1 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.d.ts +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +31 -0
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
- package/bundle/typescript/src/commands/login.js +19 -1
- package/bundle/typescript/src/commands/login.js.map +1 -1
- package/bundle/typescript/src/commands/logout.js +16 -1
- package/bundle/typescript/src/commands/logout.js.map +1 -1
- package/bundle/typescript/src/tools/guarded-file/guarded-file.class.d.ts +59 -0
- package/bundle/typescript/src/tools/guarded-file/guarded-file.class.js +120 -0
- package/bundle/typescript/src/tools/guarded-file/guarded-file.class.js.map +1 -0
- package/bundle/typescript/src/tools/mpi/mpi.class.d.ts +16 -0
- package/bundle/typescript/src/tools/mpi/mpi.class.js +25 -7
- package/bundle/typescript/src/tools/mpi/mpi.class.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { getSoft, getProgram } from '../utilities/programs.js';
|
|
2
|
+
import { GEAR_LANES, CHROME_LANES, QUALITY_LANES, ART_LANES, ADEPT_LANES, scaleOf } from '../utilities/effects.js';
|
|
2
3
|
import { catalogRowFor } from '../utilities/catalog.js';
|
|
3
4
|
import { getQuality, resolveQualityKey } from '../qualities.js';
|
|
5
|
+
import { ADEPT_POWERS } from '../adept-powers.js';
|
|
6
|
+
import { skillsInGroup } from '../skill-groups.js';
|
|
7
|
+
import { spellFor } from '../utilities/catalog.js';
|
|
8
|
+
import { METAMAGICS_BY_KEY } from '../commands/initiate.js';
|
|
4
9
|
import { isAspectKey } from '../aspects.js';
|
|
5
10
|
import { hint } from '../utilities/hints.js';
|
|
6
11
|
import { Category } from '../types/shared/item-enum.js';
|
|
@@ -48,28 +53,36 @@ export function sameHostSide(actor, other) {
|
|
|
48
53
|
const sideOf = (p) => p.hostInside ?? p.insideHost;
|
|
49
54
|
return sideOf(actor) === sideOf(other);
|
|
50
55
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The SR5e-condensed active skill list. Every pool that used to lean on
|
|
58
|
+
* the single generic `combatSkill` attribute now draws the SPECIFIC
|
|
59
|
+
* skill (firearms for guns, blades for melee weapons, unarmed for fists
|
|
60
|
+
* and subdual, hacking for cybercombat). Non-combat skills back their own
|
|
61
|
+
* mechanics as they land -- perception drives search rolls today; the
|
|
62
|
+
* rest are rated on the sheet and waiting for their verbs.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* The active-skill sheet, rounded out from the SR5e canon list (p.90-91,
|
|
66
|
+
* p.151) at this engine's granularity. CONDENSATIONS (canon -> here):
|
|
67
|
+
* Automatics/Longarms/Pistols/Heavy Weapons/Archery -> firearms;
|
|
68
|
+
* Gymnastics/Running/Swimming -> athletics; the Pilot family ->
|
|
69
|
+
* piloting; Summoning/Binding/Banishing -> conjuring; Impersonation
|
|
70
|
+
* rides disguise (its voice half). Every skill listed HOOKS a live
|
|
71
|
+
* system -- see the site named beside it. Canon skills acknowledged but
|
|
72
|
+
* NOT yet carried (no system to hook): Throwing Weapons, Exotic
|
|
73
|
+
* Weapons, Diving, Free-Fall, Survival, Tracking, Navigation, Animal
|
|
74
|
+
* Handling, Demolitions, Forgery, Chemistry, Armorer, the Mechanic
|
|
75
|
+
* family, Biotechnology, Cybertechnology, Arcana, Alchemy, Artificing,
|
|
76
|
+
* Ritual Spellcasting, Disenchanting, and Knowledge/Language skills.
|
|
77
|
+
* (The Resonance trio -- Compiling, Decompiling, Registering -- WAS on
|
|
78
|
+
* this not-yet-carried list; all three are now real and hooked, one per
|
|
79
|
+
* command file beside them.)
|
|
80
|
+
*/
|
|
81
|
+
// The skill vocabulary now lives in skills.ts, a module with no imports
|
|
82
|
+
// of its own, so skill-groups.ts can read it without a cycle back
|
|
83
|
+
// through this file. Re-exported so nothing that imports SkillName or
|
|
84
|
+
// SKILL_NAMES from here has to change.
|
|
85
|
+
export { SKILL_NAMES } from '../skills.js';
|
|
73
86
|
/**
|
|
74
87
|
* Skills that fall back to the legacy `combatSkill` attribute when no
|
|
75
88
|
* explicit rating is given -- this is what keeps every existing NPC seed
|
|
@@ -824,8 +837,8 @@ export class Player extends AbstractPlayer {
|
|
|
824
837
|
const silent = this.runningSilent ? 2 : 0;
|
|
825
838
|
// What the running programs add (catalog v13): Signal Scrub always,
|
|
826
839
|
// Sneak only while silent -- the rows carry both, and the `only`.
|
|
827
|
-
const scrub = this.
|
|
828
|
-
const sneak = this.runningSilent ? this.
|
|
840
|
+
const scrub = this.bonus('pan-defense');
|
|
841
|
+
const sneak = this.runningSilent ? this.bonus('pan-defense', { only: 'silent' }) - scrub : 0;
|
|
829
842
|
const attributeName = against === 'attack' ? 'Willpower' : 'Intuition';
|
|
830
843
|
const attribute = against === 'attack' ? this.willpower : this.intuition;
|
|
831
844
|
const firewall = this.isTechnomancer()
|
|
@@ -962,6 +975,11 @@ export class Player extends AbstractPlayer {
|
|
|
962
975
|
* check lives here and not at either call site.
|
|
963
976
|
*/
|
|
964
977
|
smartlinkTier() {
|
|
978
|
+
// CHROME SPECIFICALLY, not every source of firearms dice. This is
|
|
979
|
+
// asking which TIER of smartlink you have -- the implant behind
|
|
980
|
+
// your eye (p.453) or the unit clipped to the gun (p.432) -- and
|
|
981
|
+
// `bonus` would answer true for the gear one and collapse the very
|
|
982
|
+
// distinction the method exists to draw.
|
|
965
983
|
if (this.augBonus('firearms') > 0)
|
|
966
984
|
return 'augmentation';
|
|
967
985
|
// ON YOUR FACE, NOT IN YOUR BAG (fxPHSuE4nrH3gAGiy, rejected:
|
|
@@ -1295,19 +1313,39 @@ export class Player extends AbstractPlayer {
|
|
|
1295
1313
|
if (getAug(aug.key)?.implantsCarriedDeck)
|
|
1296
1314
|
this.getAnyCyberdeck()?.implantIntoBody();
|
|
1297
1315
|
}
|
|
1298
|
-
//
|
|
1299
|
-
// A catalog row says what it
|
|
1300
|
-
// place the engine reads that
|
|
1301
|
-
// is
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1316
|
+
// ================= THE EFFECTS LANE (one resolver) =================
|
|
1317
|
+
// A catalog row says what it DOES (`effects`), and this is the one
|
|
1318
|
+
// place the engine reads that -- for every kind of row, not just
|
|
1319
|
+
// gear. What is worn (and fitted into it), what is drawn, what is
|
|
1320
|
+
// carried, what runs on the deck, what is stored on a commlink, the
|
|
1321
|
+
// chrome that is installed, the qualities held, the adept powers and
|
|
1322
|
+
// metamagics a character simply has, and the spells being sustained.
|
|
1323
|
+
//
|
|
1324
|
+
// WHY ONE RESOLVER (user, 2026-09-04: "no matter if it's a quality,
|
|
1325
|
+
// augmentation, metatype ability, it has a lane to follow to apply
|
|
1326
|
+
// stats to the character"). Before this there were six shapes and six
|
|
1327
|
+
// readers, and every pool in the game hand-added whichever ones its
|
|
1328
|
+
// author remembered -- which is why a rigger's chrome worked on a
|
|
1329
|
+
// drone and silently did nothing on a bench repair two files away.
|
|
1330
|
+
// A term that has to be remembered is a term that gets forgotten.
|
|
1331
|
+
/**
|
|
1332
|
+
* Which SOURCE an effect came from. The single accessor `bonus()`
|
|
1333
|
+
* sums them all; the older per-source getters each sum their own,
|
|
1334
|
+
* which is what lets the call sites migrate one at a time instead of
|
|
1335
|
+
* in one unreviewable commit.
|
|
1336
|
+
*/
|
|
1337
|
+
/** Every effect applying right now, with its source and its scale. */
|
|
1305
1338
|
applyingEffects() {
|
|
1306
1339
|
const out = [];
|
|
1307
|
-
|
|
1340
|
+
// `slug` is which ROW the effect came from, and it is what a
|
|
1341
|
+
// `chosen` property resolves through. Recorded here rather than
|
|
1342
|
+
// searched for later: the first version of pickFor walked the
|
|
1343
|
+
// qualities looking for an array that CONTAINED the effect object,
|
|
1344
|
+
// which worked only for qualities and only by object identity.
|
|
1345
|
+
const take = (effects, defaultWhen, state, rating, lane = 'gear', slug = '') => {
|
|
1308
1346
|
for (const e of effects)
|
|
1309
1347
|
if ((e.when ?? defaultWhen) === state)
|
|
1310
|
-
out.push({ effect: e, rating: rating ?? 1 });
|
|
1348
|
+
out.push({ effect: e, rating: rating ?? 1, lane, slug });
|
|
1311
1349
|
};
|
|
1312
1350
|
for (const [groupName, group] of Object.entries(this._equipment)) {
|
|
1313
1351
|
if (!group)
|
|
@@ -1317,14 +1355,14 @@ export class Player extends AbstractPlayer {
|
|
|
1317
1355
|
continue;
|
|
1318
1356
|
if (Player.isWeaponSlot(groupName, slot)) {
|
|
1319
1357
|
if (this.weaponDrawn)
|
|
1320
|
-
take(item.effects, item.defaultWhen, 'held', item.ratingValue);
|
|
1358
|
+
take(item.effects, item.defaultWhen, 'held', item.ratingValue, 'gear', item.row?.slug ?? item.name);
|
|
1321
1359
|
continue;
|
|
1322
1360
|
}
|
|
1323
|
-
take(item.effects, item.defaultWhen, 'worn', item.ratingValue);
|
|
1361
|
+
take(item.effects, item.defaultWhen, 'worn', item.ratingValue, 'gear', item.row?.slug ?? item.name);
|
|
1324
1362
|
}
|
|
1325
1363
|
}
|
|
1326
1364
|
for (const item of this._inventory.getAllItems())
|
|
1327
|
-
take(item.effects, item.defaultWhen, 'carried', item.ratingValue);
|
|
1365
|
+
take(item.effects, item.defaultWhen, 'carried', item.ratingValue, 'gear', item.row?.slug ?? item.name);
|
|
1328
1366
|
const runningOn = new Set();
|
|
1329
1367
|
const deck = this.activeDeck ?? this.getCyberdeck();
|
|
1330
1368
|
if (deck)
|
|
@@ -1337,7 +1375,7 @@ export class Player extends AbstractPlayer {
|
|
|
1337
1375
|
const p = getProgram(key);
|
|
1338
1376
|
const row = p ? catalogRowFor(`${p.name} Program`) ?? catalogRowFor(p.name) : undefined;
|
|
1339
1377
|
if (row?.effects)
|
|
1340
|
-
take(row.effects, 'running', 'running', row.ratingValue);
|
|
1378
|
+
take(row.effects, 'running', 'running', row.ratingValue, 'gear', row.slug);
|
|
1341
1379
|
}
|
|
1342
1380
|
}
|
|
1343
1381
|
for (const device of [...this.commlinks(), ...this._inventory.getAllItems().filter(i => i.isCyberdeck())]) {
|
|
@@ -1345,62 +1383,265 @@ export class Player extends AbstractPlayer {
|
|
|
1345
1383
|
const soft = getSoft(key);
|
|
1346
1384
|
const row = soft ? catalogRowFor(soft.name) : undefined;
|
|
1347
1385
|
if (row?.effects)
|
|
1348
|
-
take(row.effects, 'stored', 'stored', row.ratingValue);
|
|
1386
|
+
take(row.effects, 'stored', 'stored', row.ratingValue, 'gear', row.slug);
|
|
1349
1387
|
}
|
|
1350
1388
|
}
|
|
1389
|
+
// ---- The CHROME lane. Installed 'ware, with ONE relationship the
|
|
1390
|
+
// rows cannot state themselves: the wired tiers SUPERSEDE each other
|
|
1391
|
+
// (III beats II beats I) rather than stacking, and a synaptic booster
|
|
1392
|
+
// rides on top as bioware. Resolved by dropping the superseded rows
|
|
1393
|
+
// here, once, so every reader sees the same surviving set -- rather
|
|
1394
|
+
// than in a getter, where only that getter would have been right.
|
|
1395
|
+
for (const key of this.chromeThatCounts()) {
|
|
1396
|
+
const a = getAug(key);
|
|
1397
|
+
if (a?.effects)
|
|
1398
|
+
take(a.effects, 'installed', 'installed', undefined, 'chrome', key);
|
|
1399
|
+
}
|
|
1400
|
+
// ---- The ADEPT lane. A power is 'innate': there is no object to
|
|
1401
|
+
// carry and no state to enter, and its LEVEL is what "per level"
|
|
1402
|
+
// scales by. A character with no `adeptPowers` at all is on the
|
|
1403
|
+
// compressed legacy model and contributes nothing here -- that shape
|
|
1404
|
+
// is resolved in adeptAttackBonus/adeptDefenseBonus, which is where
|
|
1405
|
+
// it has always lived and where the tests that pin it can see it.
|
|
1406
|
+
for (const [key, level] of Object.entries(this.adeptPowers ?? {})) {
|
|
1407
|
+
if (level <= 0)
|
|
1408
|
+
continue;
|
|
1409
|
+
const power = ADEPT_POWERS[key];
|
|
1410
|
+
if (power?.effects)
|
|
1411
|
+
take(power.effects, 'innate', 'innate', level, 'adept', key);
|
|
1412
|
+
}
|
|
1413
|
+
// ---- The INITIATE lane. A metamagic applies from the moment it is
|
|
1414
|
+
// learned, and 'grade' is what Centering multiplies by.
|
|
1415
|
+
for (const key of this.metamagics) {
|
|
1416
|
+
const m = METAMAGICS_BY_KEY[key];
|
|
1417
|
+
if (m?.effects)
|
|
1418
|
+
take(m.effects, 'initiated', 'initiated', this.initiationGrade, 'initiate', key);
|
|
1419
|
+
}
|
|
1420
|
+
// ---- The SUSTAINED lane. A spell held up applies until it is
|
|
1421
|
+
// dropped, and the magnitude it scales by is the hits it landed --
|
|
1422
|
+
// read from the state cast.ts already keeps rather than duplicated
|
|
1423
|
+
// into a second map. Barriers are absent on purpose: they raise a
|
|
1424
|
+
// wall in a room (models/scene.ts reads them), and that is world
|
|
1425
|
+
// state rather than a modifier on anybody.
|
|
1426
|
+
for (const [key, hits] of this.sustainedMagnitudes()) {
|
|
1427
|
+
const sp = spellFor(key);
|
|
1428
|
+
if (sp?.effects)
|
|
1429
|
+
take(sp.effects, 'sustained', 'sustained', hits, 'sustained', key);
|
|
1430
|
+
}
|
|
1431
|
+
// ---- The QUALITY lane. A quality is "held" in the same sense a
|
|
1432
|
+
// coat is worn: it is on the sheet, so it applies. A RETIRED one is
|
|
1433
|
+
// dormant rather than deleted (qualities.ts) -- its holder keeps it
|
|
1434
|
+
// and it rolls nothing, which is enforced here, once, rather than
|
|
1435
|
+
// at each reader.
|
|
1436
|
+
for (const key of this.qualities) {
|
|
1437
|
+
const q = getQuality(key);
|
|
1438
|
+
if (!q?.effects || q.retired)
|
|
1439
|
+
continue;
|
|
1440
|
+
take(q.effects, 'held-quality', 'held-quality', this.qualityRating(key), 'quality', key);
|
|
1441
|
+
}
|
|
1442
|
+
return out;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Sums one kind of effect. `lanes` narrows to particular sources --
|
|
1446
|
+
* omitted means EVERY source, which is what the unified `bonus()`
|
|
1447
|
+
* wants; the legacy getters each pass their own single lane so that
|
|
1448
|
+
* a call site still adding two of them by hand cannot double.
|
|
1449
|
+
*/
|
|
1450
|
+
/**
|
|
1451
|
+
* The spells being sustained, and what each scales by.
|
|
1452
|
+
*
|
|
1453
|
+
* READS THE STATE cast.ts ALREADY KEEPS rather than a second map:
|
|
1454
|
+
* the armor spell stores its hits, and the other two are booleans
|
|
1455
|
+
* whose rows carry a flat value, so 1 is the right multiplier for
|
|
1456
|
+
* them. Adding a parallel store would be two places to forget to
|
|
1457
|
+
* update, which is the shape this whole pass has been removing.
|
|
1458
|
+
*/
|
|
1459
|
+
sustainedMagnitudes() {
|
|
1460
|
+
const out = [];
|
|
1461
|
+
if (this._sustainedArmor > 0)
|
|
1462
|
+
out.push(['armor', this._sustainedArmor]);
|
|
1463
|
+
if (this.sustainedInvisibility)
|
|
1464
|
+
out.push(['invisibility', 1]);
|
|
1465
|
+
if (this.sustainedReflexes)
|
|
1466
|
+
out.push(['reflexes', 1]);
|
|
1351
1467
|
return out;
|
|
1352
1468
|
}
|
|
1353
|
-
sumEffects(type, property, only) {
|
|
1469
|
+
sumEffects(type, property, only, lanes) {
|
|
1354
1470
|
let total = 0;
|
|
1355
|
-
for (const { effect, rating } of this.applyingEffects()) {
|
|
1356
|
-
if (
|
|
1471
|
+
for (const { effect, rating, lane, slug } of this.applyingEffects()) {
|
|
1472
|
+
if (lanes !== undefined && !lanes.includes(lane))
|
|
1473
|
+
continue;
|
|
1474
|
+
// FORBIDDEN never sums. It carries no value on purpose, and a
|
|
1475
|
+
// summing reader must not be able to fold it in as a zero --
|
|
1476
|
+
// "you cannot have this" is a different statement from "this
|
|
1477
|
+
// adds nothing", and only the second one is arithmetic.
|
|
1478
|
+
if (effect.type === 'forbid')
|
|
1479
|
+
continue;
|
|
1480
|
+
if (effect.type !== type)
|
|
1481
|
+
continue;
|
|
1482
|
+
// A `chosen` property belongs to whatever the holder named, and
|
|
1483
|
+
// it resolves the same way for dice and flags as it already did
|
|
1484
|
+
// for ceilings. It did NOT until 2026-09-04: only capBonus knew
|
|
1485
|
+
// the word, so a quality like Codeslinger -- +2 to ONE Matrix
|
|
1486
|
+
// action you pick -- could not be written as an effect at all,
|
|
1487
|
+
// and baked its answer into the slug instead.
|
|
1488
|
+
const target = effect.property === 'chosen' ? this.picks.get(slug) : effect.property;
|
|
1489
|
+
if (target !== property)
|
|
1357
1490
|
continue;
|
|
1358
1491
|
if (effect.only !== undefined && effect.only !== only)
|
|
1359
1492
|
continue;
|
|
1360
|
-
total += effect.value * (effect
|
|
1493
|
+
total += effect.value * scaleOf(effect, rating);
|
|
1361
1494
|
}
|
|
1362
1495
|
return total;
|
|
1363
1496
|
}
|
|
1364
1497
|
/**
|
|
1365
|
-
* Dice the
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1498
|
+
* Dice the GEAR LANE adds, and nothing else.
|
|
1499
|
+
*
|
|
1500
|
+
* A LANE READER, NOT A POOL. Every pool in the game reads `bonus()`
|
|
1501
|
+
* now; this survives as the way to ask what one source contributes,
|
|
1502
|
+
* which is what the behaviour fixture interrogates and what a "where
|
|
1503
|
+
* did that die come from" answer would need. It has no production
|
|
1504
|
+
* caller of its own, and that is the correct state for it rather than
|
|
1505
|
+
* a sign it should go: deleting it would delete the fixture's ability
|
|
1506
|
+
* to see the lanes apart, which is the only thing that can catch one
|
|
1507
|
+
* lane being counted twice.
|
|
1368
1508
|
*/
|
|
1369
1509
|
gearBonus(kind, opts = {}) {
|
|
1370
|
-
return this.sumEffects('dice', kind, opts.only);
|
|
1510
|
+
return this.sumEffects('dice', kind, opts.only, GEAR_LANES);
|
|
1371
1511
|
}
|
|
1372
1512
|
/** What the gear adds to a LIMIT (a Shopsoft's +1 Social on a haggle). */
|
|
1373
1513
|
gearLimit(kind, opts = {}) {
|
|
1514
|
+
return this.sumEffects('limit', kind, opts.only, GEAR_LANES);
|
|
1515
|
+
}
|
|
1516
|
+
// ---------------- THE UNIFIED ACCESSORS ----------------
|
|
1517
|
+
// Four calls, every source. A pool written against these cannot lose
|
|
1518
|
+
// a term to a new kind of catalog row, because there is nothing left
|
|
1519
|
+
// for it to forget to add.
|
|
1520
|
+
/**
|
|
1521
|
+
* EVERY die the character has for this pool or skill, from every
|
|
1522
|
+
* source -- gear, chrome, qualities, adept powers, metamagics, a
|
|
1523
|
+
* metatype trait, a sustained spell.
|
|
1524
|
+
*
|
|
1525
|
+
* `only` names a narrowing an effect may carry ('haggle', 'silent',
|
|
1526
|
+
* 'matrix-search'). `skill` lets a combat pool pick up dice that were
|
|
1527
|
+
* declared against the SKILL being rolled rather than the pool --
|
|
1528
|
+
* a smartlink says `firearms`, and the attack pool has to see it.
|
|
1529
|
+
*
|
|
1530
|
+
* The sources that have not yet moved onto the shared shape are added
|
|
1531
|
+
* explicitly below, each named, so that migrating one is a matter of
|
|
1532
|
+
* deleting its line here rather than hunting for what it fed.
|
|
1533
|
+
*/
|
|
1534
|
+
bonus(kind, opts = {}) {
|
|
1535
|
+
let total = this.sumEffects('dice', kind, opts.only);
|
|
1536
|
+
if (opts.skill !== undefined && opts.skill !== kind) {
|
|
1537
|
+
total += this.sumEffects('dice', opts.skill, opts.only);
|
|
1538
|
+
}
|
|
1539
|
+
// Chrome moved onto the shared shape (2026-09-04), so it arrives
|
|
1540
|
+
// through the lane above and must NOT be added again here. Every
|
|
1541
|
+
// source that migrates loses its hand-added line at the same moment
|
|
1542
|
+
// -- the fixture is what makes forgetting that loud instead of a
|
|
1543
|
+
// quietly doubled pool.
|
|
1544
|
+
// THE FAN-IN. Hacking is Matrix work, so whatever taxes the Matrix
|
|
1545
|
+
// taxes hacking too (Simsense Vertigo, p.83). It is a relationship
|
|
1546
|
+
// between two POOLS, which no single row can declare, so it lives
|
|
1547
|
+
// here -- and it applies across every lane, not only qualities.
|
|
1548
|
+
//
|
|
1549
|
+
// That is a wider reading than the getter it replaces: today
|
|
1550
|
+
// `qualityBonus('hacking')` folds in only the QUALITY side, so a
|
|
1551
|
+
// datajack helps a Matrix action and does nothing for a hacking
|
|
1552
|
+
// test. Both readings cannot be right, and the canon sentence does
|
|
1553
|
+
// not care where the dice came from. The change is deliberate, and
|
|
1554
|
+
// lands where a call site adopts `bonus()` -- the fixture prints
|
|
1555
|
+
// the exact pools that move when it does.
|
|
1556
|
+
if (kind === 'hacking')
|
|
1557
|
+
total += this.bonus('matrix', opts);
|
|
1558
|
+
return total;
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* What a source adds to the initiative SCORE -- the number you roll
|
|
1562
|
+
* against -- as opposed to `bonus('initiative')`, which is the DICE.
|
|
1563
|
+
*
|
|
1564
|
+
* The `initiative` effect arm has existed since the gear pass and
|
|
1565
|
+
* was read by nothing at all until the Increase Reflexes spell came
|
|
1566
|
+
* onto the lane (2026-09-04). Two different quantities sharing the
|
|
1567
|
+
* word "initiative" is exactly why the arm is separate.
|
|
1568
|
+
*/
|
|
1569
|
+
initiativeBonus(of) {
|
|
1570
|
+
return this.sumEffects('initiative', of);
|
|
1571
|
+
}
|
|
1572
|
+
/** Every point on a LIMIT, from every source. */
|
|
1573
|
+
limitBonus(kind, opts = {}) {
|
|
1374
1574
|
return this.sumEffects('limit', kind, opts.only);
|
|
1375
1575
|
}
|
|
1376
|
-
/**
|
|
1377
|
-
|
|
1576
|
+
/**
|
|
1577
|
+
* How much further this character may raise one skill or attribute
|
|
1578
|
+
* than anyone else -- Aptitude's chosen skill (p.72), Exceptional
|
|
1579
|
+
* Attribute's chosen attribute (p.73-74).
|
|
1580
|
+
*
|
|
1581
|
+
* The book states absolute numbers against ITS ceilings (a skill at 7
|
|
1582
|
+
* rather than 6 at creation, 13 rather than 12 after). This engine
|
|
1583
|
+
* caps lower than the book does, so what carries across is the DELTA
|
|
1584
|
+
* -- one step further than anyone else gets -- applied to whatever
|
|
1585
|
+
* ceiling the engine is enforcing. Writing the book's 13 over a house
|
|
1586
|
+
* cap of 7 would not be more faithful, only a bigger number in the
|
|
1587
|
+
* wrong system.
|
|
1588
|
+
*
|
|
1589
|
+
* A `chosen` ceiling resolves through the holder's own pick, so a
|
|
1590
|
+
* quality bought but never answered lifts nothing at all.
|
|
1591
|
+
*/
|
|
1592
|
+
capBonus(of, name) {
|
|
1593
|
+
const category = of === 'skill' ? 'skills' : 'attributes';
|
|
1594
|
+
let total = 0;
|
|
1595
|
+
for (const { effect, rating, slug } of this.applyingEffects()) {
|
|
1596
|
+
if (effect.type !== 'cap' || effect.category !== category)
|
|
1597
|
+
continue;
|
|
1598
|
+
const target = effect.property === 'chosen' ? this.picks.get(slug) : effect.property;
|
|
1599
|
+
if (target !== name)
|
|
1600
|
+
continue;
|
|
1601
|
+
total += effect.value * (effect.scaleBy === 'rating' ? rating : 1);
|
|
1602
|
+
}
|
|
1603
|
+
return total;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
/** A capability the character has, from any source, by key. */
|
|
1609
|
+
can(key) {
|
|
1378
1610
|
return this.sumEffects('flag', key) > 0;
|
|
1379
1611
|
}
|
|
1380
1612
|
/**
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1613
|
+
* THE CHROME THAT ACTUALLY COUNTS: everything installed, minus the
|
|
1614
|
+
* wired-reflex tiers a higher tier supersedes.
|
|
1615
|
+
*
|
|
1616
|
+
* Wired Reflexes I, II and III do not stack -- the best one wins (a
|
|
1617
|
+
* synaptic booster is bioware and rides on top of whichever that is).
|
|
1618
|
+
* That is a relationship BETWEEN rows, so no row can declare it and it
|
|
1619
|
+
* has to live in code; it lives HERE, in the one place the resolver
|
|
1620
|
+
* reads, rather than inside a getter, where only that getter would
|
|
1621
|
+
* have been right and every other reader would have double-counted.
|
|
1622
|
+
*
|
|
1623
|
+
* The synaptic-vs-wired legality conflict remains the clinic's job.
|
|
1384
1624
|
*/
|
|
1385
|
-
|
|
1625
|
+
chromeThatCounts() {
|
|
1386
1626
|
if (this.augmentations.size === 0)
|
|
1387
|
-
return
|
|
1627
|
+
return [];
|
|
1388
1628
|
const has = (k) => this.augmentations.has(k);
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1629
|
+
const best = has('wired-reflexes-3') ? 'wired-reflexes-3'
|
|
1630
|
+
: has('wired-reflexes-2') ? 'wired-reflexes-2'
|
|
1631
|
+
: has('wired-reflexes-1') ? 'wired-reflexes-1' : undefined;
|
|
1632
|
+
const superseded = new Set(['wired-reflexes-1', 'wired-reflexes-2', 'wired-reflexes-3'].filter(k => k !== best));
|
|
1633
|
+
return [...this.augmentations].filter(k => !superseded.has(k));
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The chrome lane, by pool. A LANE READER, like gearBonus above.
|
|
1637
|
+
*
|
|
1638
|
+
* Every pool now reads `bonus()`, with exactly two deliberate
|
|
1639
|
+
* exceptions, both in smartlinkTier and both commented there: that
|
|
1640
|
+
* method asks which TIER of smartlink you have, implant or clip-on,
|
|
1641
|
+
* and only the chrome lane can answer it.
|
|
1642
|
+
*/
|
|
1643
|
+
augBonus(kind) {
|
|
1644
|
+
return this.sumEffects('dice', kind, undefined, CHROME_LANES);
|
|
1404
1645
|
}
|
|
1405
1646
|
// ==================== Qualities (see qualities.ts) ====================
|
|
1406
1647
|
// SR5e's traits (p.71-85), baked into the archetype at creation
|
|
@@ -1409,6 +1650,43 @@ export class Player extends AbstractPlayer {
|
|
|
1409
1650
|
// qualityBonus below, the same single-switch pattern as augBonus.
|
|
1410
1651
|
/** Installed qualities, by catalog key (see QUALITIES). */
|
|
1411
1652
|
qualities = new Set();
|
|
1653
|
+
/**
|
|
1654
|
+
* IS THIS SKILL OR ATTRIBUTE FORBIDDEN OUTRIGHT to this character?
|
|
1655
|
+
*
|
|
1656
|
+
* Reads the same lane every other rule reads, so a second quality
|
|
1657
|
+
* (or a piece of chrome, or a metatype trait) that bars something
|
|
1658
|
+
* needs no new reader. A `chosen` forbid resolves through the
|
|
1659
|
+
* holder's pick, and where the pick names a skill GROUP the bar
|
|
1660
|
+
* falls on every member -- which is what Incompetent actually is
|
|
1661
|
+
* (p.81: "all skills in the skill group").
|
|
1662
|
+
*/
|
|
1663
|
+
isForbidden(of, name) {
|
|
1664
|
+
const category = of === 'skill' ? 'skills' : 'attributes';
|
|
1665
|
+
for (const { effect, slug } of this.applyingEffects()) {
|
|
1666
|
+
if (effect.type !== 'forbid' || effect.category !== category)
|
|
1667
|
+
continue;
|
|
1668
|
+
const target = effect.property === 'chosen' ? this.picks.get(slug) : effect.property;
|
|
1669
|
+
if (target === undefined)
|
|
1670
|
+
continue;
|
|
1671
|
+
if (target === name)
|
|
1672
|
+
return true;
|
|
1673
|
+
if (of === 'skill' && skillsInGroup(target).includes(name))
|
|
1674
|
+
return true;
|
|
1675
|
+
}
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Incompetent (p.81) names ONE skill group, and this is the only place
|
|
1680
|
+
* that knows how to ask which. It was three separate reads of
|
|
1681
|
+
* `hasQuality(`incompetent-${skill}`)` -- the slug convention -- and a
|
|
1682
|
+
* fourth that counted slugs by prefix for notoriety. The quality is
|
|
1683
|
+
* now 'incompetent' with a pick, and a legacy slug resolves to the
|
|
1684
|
+
* same thing on load (qualities.ts LEGACY_CHOICE_KEYS), so this
|
|
1685
|
+
* answers the same for an old save and a new one.
|
|
1686
|
+
*/
|
|
1687
|
+
isIncompetentAt(skill) {
|
|
1688
|
+
return this.isForbidden('skill', skill);
|
|
1689
|
+
}
|
|
1412
1690
|
hasQuality(key) {
|
|
1413
1691
|
return this.qualities.has(key);
|
|
1414
1692
|
}
|
|
@@ -1417,6 +1695,23 @@ export class Player extends AbstractPlayer {
|
|
|
1417
1695
|
* the only quality that edits an attribute (RAW raises the Edge ceiling;
|
|
1418
1696
|
* with no attribute point-buy here, the point lands directly).
|
|
1419
1697
|
*/
|
|
1698
|
+
/**
|
|
1699
|
+
* WHAT THE HOLDER PICKED, per ROW that demands a pick (ICatalogChoice):
|
|
1700
|
+
* slug -> the skill or attribute named.
|
|
1701
|
+
*
|
|
1702
|
+
* KEYED BY SLUG, NOT BY QUALITY (mjmcee, 2026-09-04). A Hardwire is an
|
|
1703
|
+
* augmentation that gives +2 to a chosen skill and asks exactly the
|
|
1704
|
+
* question Aptitude asks; a map that only qualities could reach would
|
|
1705
|
+
* mean a second map for chrome and a third for spells. One map, one
|
|
1706
|
+
* picker, one payload field.
|
|
1707
|
+
*/
|
|
1708
|
+
picks = new Map();
|
|
1709
|
+
/**
|
|
1710
|
+
* The former name for `picks`, back when only qualities could ask.
|
|
1711
|
+
* Kept as the SAME Map -- not a copy -- so a save written or read
|
|
1712
|
+
* through the old name still lands in one place.
|
|
1713
|
+
*/
|
|
1714
|
+
get qualityChoices() { return this.picks; }
|
|
1420
1715
|
/** Rating per rated quality (qualities.ts maxRating); absent = 1. */
|
|
1421
1716
|
qualityRatings = new Map();
|
|
1422
1717
|
/** The rating held, 0 when the quality is not held; a flat quality reads 1. */
|
|
@@ -1429,6 +1724,13 @@ export class Player extends AbstractPlayer {
|
|
|
1429
1724
|
// A legacy "<key>-1" resolves to the rated key at 1 (qualities.ts).
|
|
1430
1725
|
const resolved = resolveQualityKey(rawKey);
|
|
1431
1726
|
const key = resolved.key;
|
|
1727
|
+
// A legacy slug carries its pick with it (qualities.ts
|
|
1728
|
+
// LEGACY_CHOICE_KEYS): incompetent-hacking IS incompetent, having
|
|
1729
|
+
// named hacking. Recorded before anything reads it, and never
|
|
1730
|
+
// overwriting a pick the payload supplied explicitly.
|
|
1731
|
+
if (resolved.pick !== undefined && !this.picks.has(key)) {
|
|
1732
|
+
this.picks.set(key, resolved.pick);
|
|
1733
|
+
}
|
|
1432
1734
|
const q = getQuality(key);
|
|
1433
1735
|
if (q?.maxRating) {
|
|
1434
1736
|
const r = Math.max(1, Math.min(q.maxRating, Math.floor(rating ?? resolved.rating)));
|
|
@@ -1437,16 +1739,78 @@ export class Player extends AbstractPlayer {
|
|
|
1437
1739
|
if (this.qualities.has(key))
|
|
1438
1740
|
return;
|
|
1439
1741
|
this.qualities.add(key);
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1742
|
+
this.applyQualityAttributes(q);
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* A quality that MOVES AN ATTRIBUTE does it once, here, at install.
|
|
1746
|
+
*
|
|
1747
|
+
* Unlike a dice effect -- which qualityBonus re-reads on every roll
|
|
1748
|
+
* -- an attribute is a stored number, so it is edited rather than
|
|
1749
|
+
* summed. Lucky (p.76) is the long-standing case: RAW it raises the
|
|
1750
|
+
* Edge CEILING, and with no attribute point-buy here the point lands
|
|
1751
|
+
* directly. It used to be an `if (key === 'lucky')` in addQuality;
|
|
1752
|
+
* it is data now, which is what let Ugly and Doesn't Care (Cutting
|
|
1753
|
+
* Aces p.152-153) arrive as data too instead of as a second `if`.
|
|
1754
|
+
*
|
|
1755
|
+
* The floor is 1: no quality reduces an attribute below the minimum
|
|
1756
|
+
* a metahuman can have, and Ugly and Doesn't Care says so in as many
|
|
1757
|
+
* words ("minimum 1").
|
|
1758
|
+
*/
|
|
1759
|
+
applyQualityAttributes(q) {
|
|
1760
|
+
for (const e of q?.effects ?? []) {
|
|
1761
|
+
if (e.type !== 'attribute' || e.category !== 'attributes')
|
|
1762
|
+
continue;
|
|
1763
|
+
const by = e.value;
|
|
1764
|
+
switch (e.property) {
|
|
1765
|
+
case 'edge':
|
|
1766
|
+
// Edge is two numbers: the ceiling and what is left in the
|
|
1767
|
+
// pool tonight. A quality that raises one raises both, or the
|
|
1768
|
+
// point exists on the sheet and cannot be spent.
|
|
1769
|
+
this.edge = Math.max(1, this.edge + by);
|
|
1770
|
+
this.edgeRemaining = Math.max(0, this.edgeRemaining + by);
|
|
1771
|
+
break;
|
|
1772
|
+
case 'body':
|
|
1773
|
+
this.body = Math.max(1, this.body + by);
|
|
1774
|
+
break;
|
|
1775
|
+
case 'agility':
|
|
1776
|
+
this.agility = Math.max(1, this.agility + by);
|
|
1777
|
+
break;
|
|
1778
|
+
case 'reaction':
|
|
1779
|
+
this.reaction = Math.max(1, this.reaction + by);
|
|
1780
|
+
break;
|
|
1781
|
+
case 'strength':
|
|
1782
|
+
this.strength = Math.max(1, this.strength + by);
|
|
1783
|
+
break;
|
|
1784
|
+
case 'willpower':
|
|
1785
|
+
this.willpower = Math.max(1, this.willpower + by);
|
|
1786
|
+
break;
|
|
1787
|
+
case 'logic':
|
|
1788
|
+
this.logic = Math.max(1, this.logic + by);
|
|
1789
|
+
break;
|
|
1790
|
+
case 'intuition':
|
|
1791
|
+
this.intuition = Math.max(1, this.intuition + by);
|
|
1792
|
+
break;
|
|
1793
|
+
case 'charisma':
|
|
1794
|
+
this.charisma = Math.max(1, this.charisma + by);
|
|
1795
|
+
break;
|
|
1796
|
+
default: break; // magic, resonance, reach, armor-bonus: no quality moves them
|
|
1797
|
+
}
|
|
1443
1798
|
}
|
|
1444
1799
|
}
|
|
1445
1800
|
/**
|
|
1446
1801
|
* The single tap every pool reads qualities through (cf. augBonus).
|
|
1447
1802
|
*
|
|
1803
|
+
* DATA, NOT A SWITCH (2026-09-04). Every number below used to be a
|
|
1804
|
+
* `case` here; each one now lives on its own quality in qualities.ts
|
|
1805
|
+
* as an `effects` entry, in the same vocabulary gear and chrome
|
|
1806
|
+
* already speak. A quality that adds dice therefore needs no edit to
|
|
1807
|
+
* this method -- which is the property that matters, because the
|
|
1808
|
+
* cost of the old shape was that the catalog could only ever print a
|
|
1809
|
+
* quality's PROSE while its actual numbers lived somewhere a reader
|
|
1810
|
+
* could not reach.
|
|
1811
|
+
*
|
|
1448
1812
|
* `perception` and `social` mirror the augBonus kinds of the same
|
|
1449
|
-
* names
|
|
1813
|
+
* names and are read at the same call sites, so a quality and a piece
|
|
1450
1814
|
* of chrome that do the same thing are added the same way.
|
|
1451
1815
|
*
|
|
1452
1816
|
* `sleaze` and `attack` are the two Matrix ACTIONS this engine names
|
|
@@ -1455,25 +1819,51 @@ export class Player extends AbstractPlayer {
|
|
|
1455
1819
|
* a mark IS one of those two. They exist because Codeslinger and
|
|
1456
1820
|
* Codeblock (p.72 / p.80) modify ONE chosen Matrix action rather than
|
|
1457
1821
|
* Matrix work in general, so a `hacking`-wide bonus would be a
|
|
1458
|
-
* different quality wearing the name.
|
|
1822
|
+
* different quality wearing the name. In the DATA they are spelled
|
|
1823
|
+
* `matrix-attack` / `matrix-sleaze`: EffectPool already spends the
|
|
1824
|
+
* bare word `attack` on the COMBAT attack pool, and a quality that
|
|
1825
|
+
* fired in a firefight because it shared a word with a Matrix action
|
|
1826
|
+
* would be a bug nobody would look for.
|
|
1459
1827
|
*/
|
|
1460
1828
|
qualityBonus(kind) {
|
|
1461
1829
|
if (this.qualities.size === 0)
|
|
1462
1830
|
return 0;
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1831
|
+
const property = kind === 'sleaze' ? 'matrix-sleaze' : kind === 'attack' ? 'matrix-attack' : kind;
|
|
1832
|
+
let total = this.sumQualityEffects(property);
|
|
1833
|
+
// The one FAN-IN, and it is a rule rather than a number: hacking is
|
|
1834
|
+
// Matrix work, so anything that taxes the Matrix taxes hacking too
|
|
1835
|
+
// (Simsense Vertigo, p.83). Kept here because it is a relationship
|
|
1836
|
+
// between two pools, which no single quality can declare.
|
|
1837
|
+
if (kind === 'hacking')
|
|
1838
|
+
total += this.qualityBonus('matrix');
|
|
1839
|
+
return total;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Dice the QUALITIES add to one pool or skill. Deliberately its own
|
|
1843
|
+
* summation rather than a source inside applyingEffects(): the pools
|
|
1844
|
+
* are read as `augBonus(k) + qualityBonus(k)` at call sites that do
|
|
1845
|
+
* NOT also add gearBonus(k), and folding qualities into the shared
|
|
1846
|
+
* effect walk would have made every site that DOES add gearBonus
|
|
1847
|
+
* count a quality twice, silently, in a number nobody prints.
|
|
1848
|
+
*/
|
|
1849
|
+
/**
|
|
1850
|
+
* How much further this character may raise one skill or attribute
|
|
1851
|
+
* than anyone else -- Aptitude's chosen skill (p.72), Exceptional
|
|
1852
|
+
* Attribute's chosen attribute (p.73-74).
|
|
1853
|
+
*
|
|
1854
|
+
* The book states absolute numbers against ITS ceilings (a skill at
|
|
1855
|
+
* 7 rather than 6 at creation, 13 rather than 12 after). This engine
|
|
1856
|
+
* caps lower than the book does, so what carries across is the DELTA
|
|
1857
|
+
* -- one step further than anyone else gets -- applied to whatever
|
|
1858
|
+
* ceiling the engine is enforcing. Writing the book's 13 over a house
|
|
1859
|
+
* cap of 7 would not be more faithful, it would just be a bigger
|
|
1860
|
+
* number in the wrong system.
|
|
1861
|
+
*/
|
|
1862
|
+
qualityCap(of, name) {
|
|
1863
|
+
return this.capBonus(of, name);
|
|
1864
|
+
}
|
|
1865
|
+
sumQualityEffects(property) {
|
|
1866
|
+
return this.sumEffects('dice', property, undefined, QUALITY_LANES);
|
|
1477
1867
|
}
|
|
1478
1868
|
// First Impression (p.74): the first social play of each job rolls +2.
|
|
1479
1869
|
// Consumed like the edge boost and the liquid-courage die; re-armed
|
|
@@ -1576,7 +1966,7 @@ export class Player extends AbstractPlayer {
|
|
|
1576
1966
|
get qualityNotoriety() {
|
|
1577
1967
|
const plus = ['bad-luck', 'combat-paralysis', 'sinner-criminal']
|
|
1578
1968
|
.filter(k => this.qualities.has(k)).length
|
|
1579
|
-
+
|
|
1969
|
+
+ (this.qualities.has('incompetent') ? 1 : 0);
|
|
1580
1970
|
const minus = ['first-impression', 'lucky'].filter(k => this.qualities.has(k)).length;
|
|
1581
1971
|
// BAD REP CARRIES ITS OWN NUMBER (p.79): "starts play with 3 points
|
|
1582
1972
|
// of Notoriety" -- not one entry on p.368's list, which is why it is
|
|
@@ -1625,7 +2015,7 @@ export class Player extends AbstractPlayer {
|
|
|
1625
2015
|
skillRating(name) {
|
|
1626
2016
|
// Incompetent (p.81): the skill isn't low, it's ABSENT -- pinned to 0
|
|
1627
2017
|
// no matter what the sheet or the fallback would say.
|
|
1628
|
-
if (this.
|
|
2018
|
+
if (this.isForbidden('skill', name))
|
|
1629
2019
|
return 0;
|
|
1630
2020
|
const explicit = this.skills[name];
|
|
1631
2021
|
if (typeof explicit === 'number')
|
|
@@ -2368,7 +2758,7 @@ export class Player extends AbstractPlayer {
|
|
|
2368
2758
|
// A damage compensator (p.461) ignores boxes outright -- chrome that
|
|
2369
2759
|
// stacks with High Pain Tolerance, unlike the pain editor's tier skip.
|
|
2370
2760
|
const ignore = (!painEditor && this.qualities.has('high-pain-tolerance') ? 1 : 0)
|
|
2371
|
-
+ this.
|
|
2761
|
+
+ this.bonus('wound-ignore');
|
|
2372
2762
|
const dmg = Math.max(0, this._damageTaken - ignore);
|
|
2373
2763
|
const spill = Math.max(0, ignore - this._damageTaken);
|
|
2374
2764
|
const stun = Math.max(0, this._stunTaken - spill);
|
|
@@ -2556,7 +2946,7 @@ export class Player extends AbstractPlayer {
|
|
|
2556
2946
|
armorPen: implant.ap,
|
|
2557
2947
|
};
|
|
2558
2948
|
}
|
|
2559
|
-
return { name: 'bare fists', damage: this.strength + this.
|
|
2949
|
+
return { name: 'bare fists', damage: this.strength + this.bonus('unarmed-damage'), poolBonus: 0, armorPen: 0 };
|
|
2560
2950
|
}
|
|
2561
2951
|
const base = weapon.weaponDv + (weapon.dvAttribute === 'strength' ? this.strength : 0);
|
|
2562
2952
|
return { name: weapon.name, damage: base, poolBonus: 0, armorPen: weapon.armorPen };
|
|
@@ -2658,7 +3048,7 @@ export class Player extends AbstractPlayer {
|
|
|
2658
3048
|
// GUNNERY (canon: the vehicle-weapon skill) -- best-of with
|
|
2659
3049
|
// piloting so pre-gunnery riggers lose nothing.
|
|
2660
3050
|
const droneSkill = Math.max(this.skillRating('piloting'), this.skillRating('gunnery'));
|
|
2661
|
-
return Math.max(0, this.logic + droneSkill + this.
|
|
3051
|
+
return Math.max(0, this.logic + droneSkill + this.bonus('piloting') + hot + this.woundModifier - this.sustainingPenalty);
|
|
2662
3052
|
}
|
|
2663
3053
|
if (this.plane === 'matrix') {
|
|
2664
3054
|
const hot = this.simMode === 'hot' ? 2 : 0;
|
|
@@ -2674,7 +3064,7 @@ export class Player extends AbstractPlayer {
|
|
|
2674
3064
|
// for a technomancer, Resonance/2. A living persona's Attack is
|
|
2675
3065
|
// Charisma (p.251) and rides in the bracket too. See
|
|
2676
3066
|
// getAttackLimit, and utilities/dice.ts for what a limit does.
|
|
2677
|
-
return Math.max(0, this.logic + matrixSkill + hot + this.
|
|
3067
|
+
return Math.max(0, this.logic + matrixSkill + hot + this.bonus('hacking') + (this.hasMetamagic('attack-upgrade') ? 1 : 0) + this.formMatrixBonus + this.woundModifier - this.sustainingPenalty);
|
|
2678
3068
|
}
|
|
2679
3069
|
if (this.plane === 'astral') {
|
|
2680
3070
|
// ASTRAL COMBAT IS `Astral Combat + Willpower [Astral]` (p.315),
|
|
@@ -2701,7 +3091,6 @@ export class Player extends AbstractPlayer {
|
|
|
2701
3091
|
// Power Points ride every strike. Chosen powers: Improved Ability
|
|
2702
3092
|
// (Combat) is the striking bonus. See adeptAttackBonus.
|
|
2703
3093
|
// Power Point (initiation, adepts): one more point of magic-as-muscle.
|
|
2704
|
-
const adeptBonus = this.adeptAttackBonus();
|
|
2705
3094
|
// THE WIRELESS SMARTGUN BONUS IS DICE, NOT ACCURACY (p.433), and it
|
|
2706
3095
|
// has TWO TIERS this engine only had one of: "+1 if you're using
|
|
2707
3096
|
// gear with a smartlink, +2 if you're using an augmentation for
|
|
@@ -2721,6 +3110,8 @@ export class Player extends AbstractPlayer {
|
|
|
2721
3110
|
// bonus is the smartgun system talking to the smartlink.
|
|
2722
3111
|
const drawnGun = this.weaponDrawn ? this.getCarriedWeapon() : null;
|
|
2723
3112
|
const smartlink = drawnGun?.isSmartgun && FIREARM_SKILLS.includes(this.activeCombatSkill())
|
|
3113
|
+
// Chrome again, and for the same reason: the tier is already
|
|
3114
|
+
// decided above, and this branch is the implant's own dice.
|
|
2724
3115
|
? (this.smartlinkTier() === 'gear' ? 1 : this.augBonus('firearms'))
|
|
2725
3116
|
: 0;
|
|
2726
3117
|
// RUNNING COSTS YOU (p.162 Running Modifiers): -2 dice on everything
|
|
@@ -2731,7 +3122,15 @@ export class Player extends AbstractPlayer {
|
|
|
2731
3122
|
// sprinted across a room to reach someone arrived shooting exactly
|
|
2732
3123
|
// as straight as one who strolled -- and the movement budget's whole
|
|
2733
3124
|
// point is that ground has a price.
|
|
2734
|
-
|
|
3125
|
+
// ONE accessor for every source of attack dice: chrome, gear, the
|
|
3126
|
+
// gear that names the SKILL rather than the pool (a smartlink says
|
|
3127
|
+
// `firearms`), qualities, adept powers and the Power Point metamagic.
|
|
3128
|
+
// This line used to add five of those by hand and a sixth was always
|
|
3129
|
+
// one refactor away from being forgotten.
|
|
3130
|
+
return Math.max(0, this.agility + this.skillRating(this.activeCombatSkill())
|
|
3131
|
+
+ this.adeptCompressedBonus() + this.getWeaponProfile().poolBonus
|
|
3132
|
+
+ this.bonus('attack', { skill: this.activeCombatSkill() })
|
|
3133
|
+
+ smartlink + this.woundModifier - this.sustainingPenalty - this.runningPenalty - this.encumbrancePenalty);
|
|
2735
3134
|
}
|
|
2736
3135
|
/**
|
|
2737
3136
|
* The subdual pool: bare-hands work regardless of what's in the other
|
|
@@ -2739,8 +3138,8 @@ export class Player extends AbstractPlayer {
|
|
|
2739
3138
|
* See subdue.ts.
|
|
2740
3139
|
*/
|
|
2741
3140
|
getUnarmedAttackPool() {
|
|
2742
|
-
|
|
2743
|
-
|
|
3141
|
+
return Math.max(0, this.agility + this.skillRating('unarmed') + this.adeptCompressedBonus()
|
|
3142
|
+
+ this.bonus('attack', { skill: 'unarmed' }) + this.woundModifier - this.sustainingPenalty);
|
|
2744
3143
|
}
|
|
2745
3144
|
getDefensePool() {
|
|
2746
3145
|
// Reaction plus HALF intuition (rounded down) -- full SR
|
|
@@ -2750,7 +3149,7 @@ export class Player extends AbstractPlayer {
|
|
|
2750
3149
|
// reflex attribute but keep the same shape. Jumped-in: the rigger's
|
|
2751
3150
|
// own reflexes drive the airframe, control rig sharpening handling.
|
|
2752
3151
|
if (this.plane === 'drone') {
|
|
2753
|
-
return Math.max(0, this.reaction + Math.floor(this.intuition / 2) + this.
|
|
3152
|
+
return Math.max(0, this.reaction + Math.floor(this.intuition / 2) + this.bonus('piloting') + this.woundModifier - this.sustainingPenalty);
|
|
2754
3153
|
}
|
|
2755
3154
|
if (this.plane === 'matrix') {
|
|
2756
3155
|
const hot = this.simMode === 'hot' ? 1 : 0;
|
|
@@ -2767,7 +3166,7 @@ export class Player extends AbstractPlayer {
|
|
|
2767
3166
|
// matrixAttribute is the persona's Firewall WHOLE: the deck's for a
|
|
2768
3167
|
// decker, Willpower for a living persona (p.251), and a
|
|
2769
3168
|
// commlink's Device Rating for anyone else (p.222/p.234).
|
|
2770
|
-
return Math.max(0, this.intuition + this.matrixAttribute('firewall') + hot + this.
|
|
3169
|
+
return Math.max(0, this.intuition + this.matrixAttribute('firewall') + hot + this.bonus('matrix') + this.formDefenseBonus + this.woundModifier - this.sustainingPenalty);
|
|
2771
3170
|
}
|
|
2772
3171
|
if (this.plane === 'astral') {
|
|
2773
3172
|
return Math.max(0, this.intuition + Math.floor(this.magic / 2) + this.woundModifier - this.sustainingPenalty);
|
|
@@ -2776,9 +3175,9 @@ export class Player extends AbstractPlayer {
|
|
|
2776
3175
|
// Wired reflexes read it faster still. Locked in someone's grapple
|
|
2777
3176
|
// (p.195: a subdued character counts as PRONE against attacks): -2.
|
|
2778
3177
|
// Improved Reflexes rides Reaction itself (p.309), so it lands here too.
|
|
2779
|
-
const adeptBonus = this.adeptDefenseBonus() + this.adeptReactionBonus();
|
|
2780
3178
|
const grappled = this.grappledBy ? 2 : 0;
|
|
2781
|
-
return Math.max(0, this.reaction + Math.floor(this.intuition / 2) +
|
|
3179
|
+
return Math.max(0, this.reaction + Math.floor(this.intuition / 2) + this.adeptCompressedBonus(false)
|
|
3180
|
+
+ this.bonus('defense') + this.woundModifier - this.sustainingPenalty - grappled - this.encumbrancePenalty);
|
|
2782
3181
|
}
|
|
2783
3182
|
getSoakPool() {
|
|
2784
3183
|
// Matrix: firewall -- Logic plus the DECK's quality (your deck is your
|
|
@@ -2805,13 +3204,15 @@ export class Player extends AbstractPlayer {
|
|
|
2805
3204
|
}
|
|
2806
3205
|
// A loaded Armor program hardens it further (+2, p.245 cited).
|
|
2807
3206
|
const deck = this.activeDeck ?? this.getCyberdeck();
|
|
2808
|
-
return Math.max(0, (deck?.deviceRating ?? 0) + this.matrixAttribute('firewall') + this.
|
|
3207
|
+
return Math.max(0, (deck?.deviceRating ?? 0) + this.matrixAttribute('firewall') + this.bonus('matrix-soak'));
|
|
2809
3208
|
}
|
|
2810
3209
|
if (this.plane === 'astral') {
|
|
2811
3210
|
return Math.max(0, this.willpower);
|
|
2812
3211
|
}
|
|
2813
3212
|
// Mystic Armor (p.309): the adept power is armor that was never worn.
|
|
2814
|
-
|
|
3213
|
+
// The armor spell arrives through the sustained lane now, in
|
|
3214
|
+
// bonus('soak'), rather than as a term of its own.
|
|
3215
|
+
return Math.max(0, this.body + this.getArmorValue() + this.adeptArmorBonus() + this.bonus('soak'));
|
|
2815
3216
|
}
|
|
2816
3217
|
/**
|
|
2817
3218
|
* THE INITIATIVE ATTRIBUTE -- what the dice are added TO (p.159,
|
|
@@ -2929,7 +3330,7 @@ export class Player extends AbstractPlayer {
|
|
|
2929
3330
|
// of bug to have reported: the archetype it works for never sees
|
|
2930
3331
|
// it, and the one it fails for has nothing to compare against.
|
|
2931
3332
|
// matrixAttribute answers both cases from one place.
|
|
2932
|
-
return Math.max(0, this.matrixAttribute('dataProcessing') + this.intuition + this.
|
|
3333
|
+
return Math.max(0, this.matrixAttribute('dataProcessing') + this.intuition + this.bonus('matrix') + this.woundModifier);
|
|
2933
3334
|
}
|
|
2934
3335
|
if (this.plane === 'astral') {
|
|
2935
3336
|
// Intuition x 2 (p.159 chart, p.314 astral attributes).
|
|
@@ -2950,7 +3351,9 @@ export class Player extends AbstractPlayer {
|
|
|
2950
3351
|
// cannot be computed from what is stored. The flat +2 is kept as the
|
|
2951
3352
|
// engine's standing two-hit stand-in rather than inventing a dice
|
|
2952
3353
|
// grant the save file cannot justify. Reported, not guessed.
|
|
2953
|
-
|
|
3354
|
+
// Increase Reflexes declares its own +2 on the score now
|
|
3355
|
+
// (spells.ts), read through the initiative arm.
|
|
3356
|
+
const spellReflexes = this.initiativeBonus('score');
|
|
2954
3357
|
// Improved Reflexes (p.309): +level Reaction, and Reaction feeds the
|
|
2955
3358
|
// initiative score (p.160) -- the adept's speed is body speed.
|
|
2956
3359
|
return Math.max(0, this.reaction + this.adeptReactionBonus() + this.intuition + arBonus + spellReflexes + this.woundModifier);
|
|
@@ -2992,7 +3395,9 @@ export class Player extends AbstractPlayer {
|
|
|
2992
3395
|
// Improved Reflexes (p.309): "+1 Initiative Die" per level -- body
|
|
2993
3396
|
// speed, so it rides the meat line beside the chrome, and the 5D6
|
|
2994
3397
|
// ceiling in rollInitiativeScore governs it like every other source.
|
|
2995
|
-
|
|
3398
|
+
// Chrome initiative and Improved Reflexes both declare `initiative`,
|
|
3399
|
+
// so the lane carries the pair.
|
|
3400
|
+
return Math.max(1, 1 + this.bonus('initiative'));
|
|
2996
3401
|
}
|
|
2997
3402
|
/**
|
|
2998
3403
|
* If an edge boost is armed (see commands/edge.ts), consume it: returns
|
|
@@ -3140,30 +3545,47 @@ export class Player extends AbstractPlayer {
|
|
|
3140
3545
|
* Combat Sense. The Power Point metamagic stays what it always was --
|
|
3141
3546
|
* a point of magic-as-muscle on the STRIKING side only.
|
|
3142
3547
|
*/
|
|
3548
|
+
/**
|
|
3549
|
+
* The COMPRESSED adept's flat bonus, or zero for anyone who chose
|
|
3550
|
+
* powers. Split out of adeptAttackBonus so the combat pools can read
|
|
3551
|
+
* `bonus()` for every other source without counting the chosen powers
|
|
3552
|
+
* twice -- they arrive through the adept lane, and this is the only
|
|
3553
|
+
* part of the adept that never became data.
|
|
3554
|
+
*/
|
|
3555
|
+
adeptCompressedBonus(withMetamagic = true) {
|
|
3556
|
+
return this.adeptPowers === undefined ? this.adeptPoolBonus(withMetamagic) : 0;
|
|
3557
|
+
}
|
|
3143
3558
|
adeptAttackBonus() {
|
|
3559
|
+
// The COMPRESSED model is the one thing here that is not data: a save
|
|
3560
|
+
// written before powers could be chosen has no `adeptPowers` at all
|
|
3561
|
+
// and gets floor(pp/2) as a flat pool bonus. It is a legacy shape,
|
|
3562
|
+
// not a rule, and the two models never stack.
|
|
3144
3563
|
if (this.adeptPowers === undefined)
|
|
3145
3564
|
return this.adeptPoolBonus();
|
|
3146
|
-
|
|
3147
|
-
|
|
3565
|
+
// Improved Ability (Combat) from the adept lane, the Power Point
|
|
3566
|
+
// metamagic from the initiate lane -- both declare `attack`, and the
|
|
3567
|
+
// resolver adds them without either being named here.
|
|
3568
|
+
return this.sumEffects('dice', 'attack', undefined, ART_LANES);
|
|
3148
3569
|
}
|
|
3149
3570
|
adeptDefenseBonus() {
|
|
3150
3571
|
if (this.adeptPowers === undefined)
|
|
3151
3572
|
return this.adeptPoolBonus(false);
|
|
3152
|
-
|
|
3573
|
+
// Combat Sense AND Improved Reflexes both land on defence (p.308-309).
|
|
3574
|
+
return this.sumEffects('dice', 'defense', undefined, ADEPT_LANES);
|
|
3153
3575
|
}
|
|
3154
3576
|
/** Mystic Armor (p.309): +1 Armor per level. Zero under the compressed
|
|
3155
3577
|
* model -- the flat pool bonus never granted armor, so none is owed. */
|
|
3156
3578
|
adeptArmorBonus() {
|
|
3157
|
-
return this.
|
|
3579
|
+
return this.sumEffects('attribute', 'armor-bonus', undefined, ADEPT_LANES);
|
|
3158
3580
|
}
|
|
3159
|
-
/** Improved Reflexes (p.309):
|
|
3160
|
-
*
|
|
3581
|
+
/** Improved Reflexes (p.309): the initiative dice it adds. Zero under
|
|
3582
|
+
* the compressed model. */
|
|
3161
3583
|
adeptReactionBonus() {
|
|
3162
|
-
return this.
|
|
3584
|
+
return this.sumEffects('dice', 'initiative', undefined, ADEPT_LANES);
|
|
3163
3585
|
}
|
|
3164
3586
|
/** Killing Hands (p.309): unarmed strikes deal PHYSICAL damage. */
|
|
3165
3587
|
hasKillingHands() {
|
|
3166
|
-
return
|
|
3588
|
+
return this.sumEffects('flag', 'killing-hands', undefined, ADEPT_LANES) > 0;
|
|
3167
3589
|
}
|
|
3168
3590
|
isAwakened() {
|
|
3169
3591
|
return this.magic > 0;
|
|
@@ -3443,7 +3865,7 @@ export class Player extends AbstractPlayer {
|
|
|
3443
3865
|
getMagicPool() {
|
|
3444
3866
|
// A focus adds its Force to the tests it is bonded for (p.318): the
|
|
3445
3867
|
// row's effects, scaled by Force (catalog v13).
|
|
3446
|
-
const focusBonus = this.
|
|
3868
|
+
const focusBonus = this.bonus('spellcasting');
|
|
3447
3869
|
// SR-proper when the SPELLCASTING skill is rated: Magic + Spellcasting
|
|
3448
3870
|
// for the Awakened, base + skill for a book-caster. Unrated falls back
|
|
3449
3871
|
// to the legacy shapes (talent max(5, magic+2); scholar's flat 5), so
|
|
@@ -3470,9 +3892,18 @@ export class Player extends AbstractPlayer {
|
|
|
3470
3892
|
// meat-plane combat pools.
|
|
3471
3893
|
if (this.adept)
|
|
3472
3894
|
return false;
|
|
3473
|
-
|
|
3895
|
+
// THE FORMULA NAMES ITS SPELL, and this reads that name.
|
|
3896
|
+
//
|
|
3897
|
+
// It used to look for the spell name INSIDE the item name, which
|
|
3898
|
+
// could only ever have worked if a formula were called after its
|
|
3899
|
+
// spell. The five that shipped were named after their CATEGORY --
|
|
3900
|
+
// "Combat Spell Formula" -- so the test never once returned true,
|
|
3901
|
+
// and a mage could buy every formula in the shop and learn nothing.
|
|
3902
|
+
// Formulae are one row per spell now, each declaring `teaches`.
|
|
3903
|
+
//
|
|
3904
|
+
// A Grimoire is still the blanket case: a whole book, any spell.
|
|
3474
3905
|
const carried = this._inventory.getAllItems().some(i => i.category === Category.Grimoire ||
|
|
3475
|
-
(i.category === Category.SpellFormula && i.
|
|
3906
|
+
(i.category === Category.SpellFormula && i.row?.teaches === spellName));
|
|
3476
3907
|
if (this.isAwakened()) {
|
|
3477
3908
|
return this.knownSpells === undefined
|
|
3478
3909
|
|| this.knownSpells.includes(spellName)
|