Skip to content

feat(combat): per-weapon momentum — exploding damage dice override (#134) - #135

Merged
DimitroffVodka merged 5 commits into
mainfrom
feat/weapon-momentum-issue134
Aug 30, 2026
Merged

feat(combat): per-weapon momentum — exploding damage dice override (#134)#135
DimitroffVodka merged 5 commits into
mainfrom
feat/weapon-momentum-issue134

Conversation

@DimitroffVodka

@DimitroffVodka DimitroffVodka commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Implements the enhancement half of #134: a per-weapon exploding-dice toggle that overrides the system's world-wide Momentum Mode.

The bug half of that issue is deliberately out of scope — it is system-side and was left alone at the owner's direction.

What this adds

An Exploding damage dice toggle on the Weapon → Bonuses tab. When on, that weapon's damage dice explode regardless of the Shadowdark system's Momentum Mode setting — its base damage, its configured damage bonuses, and its critical extra dice.

Stored as flags.shadowdark-extras.weaponBonus.momentum, inside the existing weapon-bonus config, so it inherits the tab's enabled master switch and the WEAPON_BONUSES feature gate rather than adding parallel ones.

The decision that shapes the behaviour

applyExploding exists in two forms in the wild: stock SD 4.0.6 uses /i and explodes only the first dice term (the bug half of #134); a one-character local patch to /ig explodes all of them, and such installs exist.

This module deliberately models neither. The rule it rests on is structural: when Momentum Mode is on, the system owns that formula and SDX keeps off it. Hence two predicates:

Predicate Used for Checks Momentum Mode?
shouldExplodeSystemFormula formulas handed to shadowdark.dice.roll Yes — requires it OFF, or the system double-applies
shouldExplodeOwnRoll Rolls built locally in calculateWeaponBonusDamage No — these never reach the system's roll()

SDX's rollFromConfig wrapper runs before the system's roll(). Neither system variant skips a term that already explodes, so pre-exploding while Momentum Mode is on yields a doubled 1d8xx either way — they differ only in how many terms they double. The test replicates both and pins the collision for each.

applyExplodingAll is global and idempotent; neither system variant is. That idempotence is the whole difference and is what makes a double pass harmless.

Resulting behaviour

Momentum Mode Weapon toggle Main damage roll Separately rolled bonuses
off off
off on explodes (SDX, all terms) explodes (SDX)
on off explodes (system) — ← the untouched bug half of #134
on on explodes (system) explodes (SDX)

On a stock system the "explodes (system)" cells cover only the first die of the formula. That limitation is the issue's bug half and is not addressed here.

Review fixes

Three rounds of review landed in 6bf8698f, 136d7afc and cc49206a:

  • Modifier detection was wrong in both directions. A /(?:^|[^a-z])x/ search read the x in 1d6max3 as an explode (never exploding it) and could not see the x after kh in 2d6khx, appending a second modifier for 2d6khxx — a silent damage inflation. Modifier strings are now walked token by token against Foundry's modifier list, longest-first. Unrecognised text fails closed.
  • 1d(6+2) and 4dF were skipped by a numeric-only faces pattern. Parenthesised faces now explode; Fate dice are matched and deliberately not exploded (max face +1).
  • Momentum leaked through a disabled feature. calculateWeaponBonusDamage still runs for items retaining weaponBonus.enabled when Weapon Bonuses is off but Enhanced Damage Cards is on; the local-roll path now checks WEAPON_BONUSES as the system-formula path already did.
  • Display formulas did not match what was rolled. bonusFormula, damageComponents[].formula, criticalExtraDiceFormula and criticalFormula now carry the evaluated formula, so a bonus rolled as 1d6x no longer reports as 1d6 beside a result of 15.
  • Reroll after a setting flip is pinned by a test rather than a caveat; rerolls skip the system's transform and this pass is idempotent.
  • Three repeated ternaries folded into one local withMomentum helper.

One finding was refuted: a guard on config.damageRoll.explode was requested, but no such path exists in SD 4.0.6. explod appears four times in the compiled build Foundry loads — a doc comment, the function, its single call site, and its namespace export — and that call site is guarded by useMomentumMode alone. There is no explode field in template.json or the data model for an Active Effect to target.

Verification

  • 1496/1496 tests pass (22 new in dev/tests/weapon-momentum.test.mjs)
  • npm run verifyOK; all four structural gates → OK
  • eslintzero output on every touched file

Note on the flag snapshot

dev/snapshots/flag-keys.json was already stale on main before this work. Regenerating swept in informational line-number churn for enhanced-header.mjs (+14) and npc-feature-macros.mjs (+1), which this change does not touch. Only +"weaponBonus.momentum" and the two roll-patches.mjs shifts belong to this PR.

Not yet done

Not verified in a live world — and note that this machine's Shadowdark install is locally patched to /ig, so testing here exercises the patched path, not what users on a stock system get.

)

Adds an "Exploding damage dice" toggle to the Weapon Bonuses tab. When on, the
weapon's damage dice explode regardless of the Shadowdark system's world-wide
Momentum Mode setting, which is what building a single custom exploding weapon
needs. It covers the weapon's base damage, its configured damage bonuses, and
critical extra dice.

Stored as flags.shadowdark-extras.weaponBonus.momentum, inside the existing
weapon-bonus config, so it inherits the tab's `enabled` master switch and the
WEAPON_BONUSES feature gate rather than adding parallel ones.

The system is left untouched. Its applyExploding rewrites only the FIRST dice
term of a formula (the regex has no `g` flag), which is why the world setting
never reaches added bonus dice — a system-side bug, deliberately out of scope
here.

That bug does shape the design, because SDX's rollFromConfig wrapper runs
BEFORE the system's roll(). Two predicates result:

  - shouldExplodeSystemFormula, for formulas handed to shadowdark.dice.roll,
    requires Momentum Mode to be OFF. Exploding while it is on hands the system
    `1d8x`, which its regex rewrites to `1d8xx` — a second explode modifier and
    inflated damage. When the setting is on the system already explodes, so the
    override has nothing to add.
  - shouldExplodeOwnRoll, for Rolls built locally in calculateWeaponBonusDamage,
    has no such check. Those never reach the system's roll(), so there is no
    collision and the weapon flag alone governs them.

Gating on the setting rather than on the system's current behaviour is also
what makes this survive a system-side fix: if applyExploding ever gains its
missing `g` flag, the override stays dormant exactly when it should.

applyExplodingAll is global and idempotent, so a double pass — or a formula the
system already rewrote — never produces `1d8xx`.

17 tests in dev/tests/weapon-momentum.test.mjs, including the `1d8xx + 1d6x`
collision the guard prevents and the `1d6max3` case where a naive /x/ modifier
check reads the x in `max` as an explode and silently never fires.
Two corrections, no behaviour change.

The comments and the test's replica of the system's applyExploding claimed its
regex lacks the `g` flag and rewrites only the FIRST dice term. That is wrong.
SD 4.0.6 uses `/ig` in both src/dice/dice.mjs:47 and the compiled build Foundry
actually loads, so the system explodes every dice term of the formula it is
handed — folded-in damage bonuses included.

The guard in shouldExplodeSystemFormula is therefore MORE necessary, not less:
pre-exploding while the world setting is on yields `1d8xx + 1d6xx`, a second
explode modifier on every die rather than just the first. The test that pins the
collision now asserts that, and a new assertion pins the system's replica as
non-idempotent, which is the property the guard actually exists for.

What the world setting genuinely does not reach is a damage bonus rolled
separately in calculateWeaponBonusDamage, outside shadowdark.dice.roll. That,
not the regex, is why shouldExplodeOwnRoll carries no world-setting check.

Also folds the three repeated `explodeOwnRolls ? applyExplodingAll(f) : f`
ternaries in calculateWeaponBonusDamage into a single local `withMomentum`
helper, so the three roll sites read uniformly.
Retracts the correction in 6bf8698. That commit read a LOCALLY PATCHED copy of
the Shadowdark system: this machine's install has dice.mjs.orig and
shadowdark-compiled.mjs.orig backups showing stock `/i` edited to `/ig`. Stock
SD 4.0.6 does explode only the first dice term, as originally stated.

Rather than swap one hard-coded assumption for the other, the comments and tests
now assert neither. The rule that actually holds is structural: when Momentum
Mode is on the system owns that formula and SDX keeps off it, whichever variant
is installed. The guard was already correct for both — nothing about the
shipped behaviour has changed across any of these three commits.

The test replicates BOTH variants and pins the collision for each: stock yields
`1d8xx + 1d6x`, patched yields `1d8xx + 1d6xx`. Neither skips an already
exploding term, so the guard cannot be relaxed by assuming a build.

Also addresses review findings:

  - Display formulas were built from the un-exploded source, so a momentum
    weapon showed "1d6" beside a result of 15. bonusFormula,
    criticalExtraDiceFormula and criticalFormula now carry what was rolled.
  - Adds a reroll test: flipping Momentum Mode on between roll and reroll does
    not double, because the pass is idempotent and rerolls skip the system's
    own transform.
  - Corrects the wiki, which claimed SDX "leaves the roll alone" under Momentum
    Mode without noting that separately rolled bonuses still follow the weapon
    toggle.
Three review findings, one of them a live damage bug.

Modifier detection was a substring search, `/(?:^|[^a-z])x/`, which was wrong in
both directions. It read the `x` in `1d6max3` as an explode and never exploded
that term, and — the dangerous one — could not see the `x` after `kh` in
`2d6khx`, so it appended a second modifier and produced `2d6khxx`. That is a
silent damage inflation, not a cosmetic slip. Modifier strings are now walked
token by token against Foundry's modifier list, longest-first so `max` is not
read as `ma` and `xo` not as `x`. Unrecognised text now reports as already
exploding: under-exploding is a visible no-op a user can report, whereas a stray
second `x` just quietly inflates damage.

The dice-term pattern also matched only numeric faces, so `1d(6+2)` and `4dF`
were skipped. Parenthesised faces are now matched and exploded; Fate dice are
matched and deliberately NOT exploded, since their maximum face is +1 and
exploding would re-roll about a third of the pool.

calculateWeaponBonusDamage still runs for an item that kept weaponBonus.enabled
when Weapon Bonuses is disabled but Enhanced Damage Cards is not, so
shouldExplodeOwnRoll alone let momentum act through a switched-off feature. The
local-roll path now checks WEAPON_BONUSES as the system-formula path in
roll-patches already did.

The damageComponents entry kept the untransformed part.formula while the roll
used the exploded one, so buildRollBreakdown reported a bonus rolled as `1d6x`
as plain `1d6`. The formula is resolved once per part and reused by the roll,
the component and the breakdown.

22 tests, including both modifier directions, Fate dice, parenthesised faces and
the fail-closed path for unparseable modifiers.
#134 asks for exploding dice on "any weapon". Requiring Enable Weapon Bonuses
first meant a weapon that wants only exploding damage had to switch on the hit,
damage and critical bonus machinery it was not using, so weaponHasMomentum no
longer reads the `enabled` flag.

The checkbox moves out of .sdx-bonus-content and up beside the master switch as
its peer. That is not cosmetic: .sdx-bonus-content takes opacity 0.4 and
pointer-events: none when the master switch is off, so leaving the control there
while making it functional would have produced a toggle that looks disabled and
still fires.

The WEAPON_BONUSES feature gate still applies — this decouples momentum from the
per-item switch, not from the feature that owns the tab.

Verified live against a STOCK Shadowdark 4.0.6 (the local /ig patch was reverted
first): with the master switch off and momentum on, 1d8 rolls as 1d8x and
1d8 + 1d6 as 1d8x + 1d6x.
@DimitroffVodka
DimitroffVodka merged commit 8f05f2c into main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant