2014 Monk (5/7): Way of the Four Elements disciplines - #1736
Conversation
Slice 5/7 of the 2014 Monk epic (#1313). Rides the generic subclass-choice machinery (#899) end to end instead of reviving the retired discipline engine: choicesKnown["fourElementsDisciplines"] + learn/forgetSubclassChoice (with crossEditionRejection already covering the option lookup), the existing reconcileSubclassChoices/clampChoicesToCaps pair for level-down (no new reconciler), and one ABILITY_REGISTRY entry ("disciplines") for the cast. - Seed 16 disciplines (PHB'14 pp.80-81, not in SRD 5.1) as GrantedAbility rows, source "discipline", all EDITION_2014 — corrects two errors in the structural-reference-only deleted seed (34f5a4c^): Fist of Four Thunders is 2d8 not 3d8 (thunderwave), Eternal Mountain Defense's own PHB'14 text gates it at monk level 13, not 17. - New lib/classes/disciplines.ts: maxKiPerDiscipline (PHB'14 p.80's per-cast ki cap), the 7-discipline concentration set, and a ki-scaled EffectSpec via a new EffectScaling "poolStep" mode (the generalised successor to the retired discipline-only "focus" scaling). - Widen CharacterEventType with castDiscipline (the only one of the five retired 2014 discipline events that returns — migration is a plain ADD VALUE, no enum-narrowing concerns). - Retag "Warrior of the Elements" EDITION_2024 (its 2014 predecessor, Way of the Four Elements, is now a real from-scratch discipline menu, not a shared/untagged row) and add the "Way of the Four Elements" Subclass row EDITION_2014, slug monk-way-of-the-four-elements. - Owner decision (2026-08-03): build the PHB'14 "replace one discipline when you learn a new one" swap rule this slice, not as unrestricted forget. subclassChoiceSwapCadence (types.ts) is a new choose-N-cadence rule alongside swapCadenceFor (spells), reused generically for any future choose-N feature (#1516) — canSwap rides the existing subclassChoice step (no new swap-only step needed, since a choose-N swap is legal exactly when a new pick is available) and a new LevelUpSubmission.subclassChoicesForgotten field nets against learns the same way the spell swap already does. Backend-only — no frontend wiring in this slice (matches the issue's scope). Closes #1503 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn
8cbcb14 to
eaec03b
Compare
|
Claude finished @Sandersland's task in 5m 34s —— View job Code Review: 2014 Monk — Way of the Four Elements
Overall AssessmentExcellent slice. The mechanics are well-modelled, the Finding 1 — Content Bug:
|
| Discipline | saveEffect set? |
PHB'14 says |
|---|---|---|
| Fist of Four Thunders | "half" ✓ |
half on save |
| Fist of Unbroken Air | (absent) ✗ | "takes half as much damage" on success |
| Sweeping Cinder Strike | "half" ✓ |
half on save |
| Water Whip | (absent) ✗ | "takes half damage" on success |
| Gong of the Summit | "half" ✓ |
half on save |
| Flames of the Phoenix | "half" ✓ |
half on save |
| Breath of Winter | "half" ✓ |
half on save |
catalogEffectSpec passes saveEffect through as row.saveEffect ?? null. Because these two rows lack the field, the wire EffectSpec.saveEffect is null — and the frontend will show no half-damage hint on a successful save for two of the most commonly-used disciplines (Fist of Unbroken Air and Water Whip both available at L3, Water Whip is the iconic L3 ki trick).
The fix is two lines in disciplines.ts:
// Fist of Unbroken Air (~line 116)
saveEffect: "half",
// Water Whip (~line 169)
saveEffect: "half",No migration needed — these are seed-only columns on GrantedAbility. Fix this →
Finding 2 — Minor schema inconsistency: roll validator allows 0
File: packages/contracts/src/ability-ops.ts line ~51
roll: z.number().nonnegative().optional(),The contract allows roll: 0, but disciplines.ts:resolveDisciplineCast immediately rejects it for any damage discipline (roll <= 0 → throw). This is not a bug (the server-side guard catches it), but it differs from the convention in castShadowArtOpSchema where roll uses .positive(). A z.number().int().positive() here would make the contract self-documenting and consistent with the shadow-arts sibling.
Substantive things that are correct (and worth naming)
-
maxKiPerDisciplineformulamin(6, 2 + floor((monkLevel-1)/4))→ 2/3/4/5/6 at L3/L5/L9/L13/L17 matches PHB'14 p.80's table exactly. Unit test covers all breakpoints. ✓ -
CONCENTRATION_DISCIPLINESset — 7 entries are correct (gust of wind, hold person, gaseous form, fly, stoneskin, wall of fire, wall of stone all require concentration per PHB'14; thunderwave, burning hands, shatter, fireball, cone of cold, and the non-spell disciplines do not). ✓ -
Fist of Four Thunderscorrected from 3d8 → 2d8 — thunderwave's PHB'14 damage is 2d8. ✓ -
Eternal Mountain Defensegated atminLevel: 13, not 11 or 17 — the PHB'14 text states the 13th-level gate explicitly on the discipline itself; the seed header's explanation is accurate. ✓ -
Warrior of the Elements retagged
EDITION_2024once its 2014 predecessor was authored with its own slug — this avoids the "shared row resolves against nothing" trap that any untagged subclass row hits once itsSubclassrow stops beingNULL-edition. ✓ -
No new reconciler entry —
reconcileSubclassChoices(Enabler: generalize subclass choice-count + reconciler beyond Fighter/Monk (level-up Choose-N dep) #899's generic mechanism) handlesfourElementsDisciplinesfor free;discipline-reconciliation.test.tsproves this and pins the 8-entry LEVEL_GATED_RECONCILERS count. ✓ -
subclassChoicesForgottenordering — forgets before learns inSTEP_OP_BUILDERS.subclassChoicemirrors the spell-swap pattern; the comment correctly calls out why this matters even for the RAW-disallowed re-pick edge. ✓ -
assertSubclassChoiceForgetsscope — correctly rejects forgets on a key with nocanSwapstep, limits to 1 forget per choiceKey per level-up, and defers entry-existence toapplyForgetSubclassChoiceOpat apply time (sound design decision documented in-comment). ✓ -
subclassChoiceSwapCadenceplacement intypes.ts— correct; keepslevel-up-plan.tsimport-clean of Prisma transaction types. ✓ -
deployments.mdexample updated — removingcastDisciplinefrom the blocked-enum example is correct (it's now a live enum value again); addingdisciplinesReconciledis correct (still retired). ✓ -
no-disciplines-known-key.test.ts— grep-enforced guard against accidentally resurrectingdisciplinesKnown(the pre-retirement orphaned JSON key) is the right shape of invariant test. ✓
Verdict: One content bug in the seed data (two disciplines missing saveEffect: "half") that should be fixed before merge. Everything else is solid.
Summary
Slice 5 of 7 in #1313 (2014 Monk). Implements the 2014-only Way of the Four Elements subclass — Elemental Attunement + a 16-discipline ki-fueled catalog (PHB'14 pp. 80–81, not in SRD 5.1) — riding the generic subclass-choice machinery (#899) end to end rather than reviving the retired discipline engine (34f5a4c).
backend/prisma/seed/disciplines.ts— 16GrantedAbilityrows (source: "discipline", allEDITION_2014), transcribed from PHB'14 with page citations. Corrects two errors the deleted engine's seed carried: Fist of Four Thunders is 2d8 (thunderwave), not 3d8; Eternal Mountain Defense's own text gates it at monk level 13, not 17.Subclassrow "Way of the Four Elements" (EDITION_2014, slugmonk-way-of-the-four-elements); "Warrior of the Elements" retaggedEDITION_2024(its real 2014 predecessor now exists as its own subclass, so the two can no longer share an untagged row).lib/classes/disciplines.ts—maxKiPerDiscipline(PHB'14 p.80's per-cast ki cap: 2/3/4/5/6 at L3/5/9/13/17), the 7-discipline concentration set, and a newEffectScalingmode"poolStep"(ki-overspend scaling, the generalised successor to the retired discipline-only"focus"mode). OneABILITY_REGISTRYentry ("disciplines") — no new route file.CharacterEventTypewithcastDiscipline(plainADD VALUEmigration; the other four retired discipline events stay retired).subclassChoiceSwapCadencerule (the choose-N analog ofswapCadenceFor) and aLevelUpSubmission.subclassChoicesForgottenfield, netted against learns the same way the existing spell swap works — no newLevelUpStepKind.Backend-only — no frontend wiring (matches the issue's scope).
Test plan
npm run lint/npm run typecheck(root, all workspaces) greenprisma db seedrun twice: exactly 16source: "discipline"rows, allEDITION_2014, zeroedition: NULLassertEverySubclassEditionPopulatedpasses (verified via the fullclass-feature-population.test.tsrun)scripts/check-enum-narrowing.sh,check-seed-data-modules.sh,check-catalog-id-edition-guard.sh,check-class-ts-migration.sh,check-subclass-substring.shall passfallow auditclean (two new/incidental duplicate findings suppressed with reasons; one pre-existing cross-file duplicate reduced to non-blocking)🤖 Generated with Claude Code
https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn