From eaec03b27fa2172ad159b01ffed51a9aa2a83247 Mon Sep 17 00:00:00 2001 From: Steffen Andersland Date: Tue, 4 Aug 2026 22:25:38 -0400 Subject: [PATCH 1/2] feat(monk): implement 2014 Way of the Four Elements disciplines (#1503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (34f5a4cf^): 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) Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/prisma/seed.ts | 57 ++ .../__tests__/discipline-fork-reseed.test.ts | 97 +++ .../seed/__tests__/monk-2024-content.test.ts | 112 ++- backend/prisma/seed/disciplines.ts | 292 +++++++ backend/prisma/seed/monk-features.ts | 118 ++- backend/prisma/seed/subclasses.ts | 14 + backend/src/lib/activity/events.ts | 7 + .../class-features-snapshot.test.ts.snap | 758 ++++++++++++++++++ .../src/lib/classes/__tests__/actions.test.ts | 22 +- .../__tests__/class-subclasses.fixture.ts | 2 +- .../lib/classes/__tests__/disciplines.test.ts | 76 ++ .../classes/__tests__/feature-edition.test.ts | 67 +- .../no-disciplines-known-key.test.ts | 29 + .../__tests__/test-feature-rows.fixture.ts | 5 +- backend/src/lib/classes/ability-registry.ts | 15 + backend/src/lib/classes/actions.ts | 39 + backend/src/lib/classes/disciplines.ts | 252 ++++++ backend/src/lib/classes/focus-cast.ts | 14 +- backend/src/lib/classes/monk.ts | 38 +- backend/src/lib/classes/resources.ts | 2 + backend/src/lib/classes/subclass-slug.ts | 9 +- backend/src/lib/classes/types.ts | 27 + .../src/lib/combat/__tests__/effects.test.ts | 20 + backend/src/lib/combat/effects.ts | 5 +- .../discipline-reconciliation.test.ts | 153 ++++ .../leveling/__tests__/level-up-plan.test.ts | 44 + .../__tests__/level-up-submission.test.ts | 88 ++ backend/src/lib/leveling/level-up-plan.ts | 18 +- .../src/lib/leveling/level-up-submission.ts | 46 +- .../src/lib/leveling/level-up-transaction.ts | 13 +- .../__tests__/disciplines-cast.test.ts | 277 +++++++ .../disciplines-subclass-choice.test.ts | 132 +++ .../__tests__/level-up-transaction.test.ts | 86 ++ backend/src/routes/character/level-up.ts | 4 + backend/src/routes/character/resources.ts | 6 +- .../src/routes/character/subclass-choices.ts | 8 + docs/deployment.md | 2 +- packages/contracts/src/ability-ops.ts | 19 + packages/shared-types/src/effects.ts | 7 +- scripts/check-catalog-id-edition-guard.sh | 1 + scripts/check-class-ts-migration.sh | 2 +- 43 files changed, 2849 insertions(+), 137 deletions(-) create mode 100644 backend/prisma/migrations/20260804231500_add_cast_discipline_event_type/migration.sql create mode 100644 backend/prisma/seed/__tests__/discipline-fork-reseed.test.ts create mode 100644 backend/prisma/seed/disciplines.ts create mode 100644 backend/src/lib/classes/__tests__/disciplines.test.ts create mode 100644 backend/src/lib/classes/__tests__/no-disciplines-known-key.test.ts create mode 100644 backend/src/lib/classes/disciplines.ts create mode 100644 backend/src/lib/leveling/__tests__/discipline-reconciliation.test.ts create mode 100644 backend/src/routes/character/__tests__/disciplines-cast.test.ts create mode 100644 backend/src/routes/character/__tests__/disciplines-subclass-choice.test.ts diff --git a/backend/prisma/migrations/20260804231500_add_cast_discipline_event_type/migration.sql b/backend/prisma/migrations/20260804231500_add_cast_discipline_event_type/migration.sql new file mode 100644 index 00000000..23f76b99 --- /dev/null +++ b/backend/prisma/migrations/20260804231500_add_cast_discipline_event_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "CharacterEventType" ADD VALUE 'castDiscipline'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bb3334ca..e4a3375b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2031,6 +2031,7 @@ enum CharacterEventType { learnSubclassChoice forgetSubclassChoice subclassChoicesReconciled + castDiscipline // advancement (ASI + feats) abilityScoreImprovement featTaken diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 12cb58cf..87d71480 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -9,6 +9,7 @@ import { CLASSES, BACKGROUNDS, ITEMS, type CatalogItem } from "./seed/catalog-da import { ACTIONS } from "./seed/actions.js"; import { MANEUVERS } from "./seed/maneuvers.js"; import { SHADOW_ARTS } from "./seed/shadow-arts.js"; +import { DISCIPLINES } from "./seed/disciplines.js"; import { CHANNEL_DIVINITIES } from "./seed/channel-divinity.js"; import { SUBCLASS_CHOICE_OPTIONS } from "./seed/subclass-choices.js"; import { FEATS } from "./seed/feats.js"; @@ -204,6 +205,60 @@ async function seedShadowArts(prisma: PrismaClient) { await prisma.grantedAbility.deleteMany({ where: staleWhere }); } +// Seed the Way of the Four Elements discipline catalog (2014-only, #1503) — +// upsert by (name, edition). Unlike seedSubclassChoiceOptions below, each row +// carries its own cost (Ki, "pool") and, where the discipline deals damage, an +// EffectSpec — the cast handler (lib/classes/disciplines.ts) reads both. +async function seedDisciplines(prisma: PrismaClient) { + for (const discipline of DISCIPLINES) { + const data = { + name: discipline.name, + edition: discipline.edition, + source: "discipline", + description: discipline.description, + minLevel: discipline.minLevel, + alwaysKnown: orElse(discipline.alwaysKnown, false), + costKind: discipline.costKind, + costPoolKey: orNull(discipline.costPoolKey), + costBase: orNull(discipline.costBase), + costPerStep: orNull(discipline.costPerStep), + effectKind: orNull(discipline.effectKind), + effectDiceCount: orNull(discipline.effectDiceCount), + effectDiceFaces: orNull(discipline.effectDiceFaces), + damageType: orNull(discipline.damageType), + attackType: orNull(discipline.attackType), + saveAbility: orNull(discipline.saveAbility), + saveEffect: orNull(discipline.saveEffect), + }; + await upsertEditionRow( + prisma.grantedAbility, + { name: discipline.name, edition: discipline.edition }, + data, + data, + ); + } + // Every seeded row is EDITION_2014 (Way of the Four Elements has no 2024 + // counterpart — 2024's Warrior of the Elements is a from-scratch rebuild, + // not a discipline menu), so the NULL and EDITION_2024 partitions both get + // `notIn: []`, matching every `source: "discipline"` row in them — + // deliberately: this is what sweeps the 17 orphaned pre-#1373-retirement + // rows (edition: NULL, a stale *Fangs of the Fire Snake* etc. snapshot) + // still sitting in a long-lived dev database (#1503's own decision comment, + // 2026-08-03). The same NULL-partition-empties-to-notIn-everything shape is + // a DATA-LOSS bug the other direction — see prune.ts's own header — but + // here, with an all-EDITION_2014 seeded list, it is the intended cleanup. + const staleWhere = staleCatalogRowsWhere( + "name", + DISCIPLINES.map((d) => ({ identity: d.name, edition: d.edition })), + { source: "discipline" }, + ); + const stale = await prisma.grantedAbility.findMany({ where: staleWhere, select: { name: true, edition: true } }); + if (stale.length) { + console.log(`seedDisciplines: dropping stale catalog rows: ${stale.map((d) => `${d.name} (${d.edition ?? "shared"})`).join(", ")}`); + } + await prisma.grantedAbility.deleteMany({ where: staleWhere }); +} + // Seed generic subclass "choose N" options (#899) as GrantedAbility rows keyed // by `source` = the choice's catalogSource. Plain descriptive features — no // cost/effect columns. @@ -469,6 +524,7 @@ async function main() { ...SHADOW_ARTS, ...CHANNEL_DIVINITIES, ...SUBCLASS_CHOICE_OPTIONS, + ...DISCIPLINES, ]); await seedSpecies(prisma); // #1682: trait content, resolved against the Species/SpeciesVariant rows @@ -481,6 +537,7 @@ async function main() { await seedActions(prisma); await seedManeuvers(prisma); await seedShadowArts(prisma); + await seedDisciplines(prisma); await seedChannelDivinities(prisma); await seedSubclassChoiceOptions(prisma); await seedFeats(prisma); diff --git a/backend/prisma/seed/__tests__/discipline-fork-reseed.test.ts b/backend/prisma/seed/__tests__/discipline-fork-reseed.test.ts new file mode 100644 index 00000000..1037f81c --- /dev/null +++ b/backend/prisma/seed/__tests__/discipline-fork-reseed.test.ts @@ -0,0 +1,97 @@ +// Proves seedDisciplines' shape (#1503): upsertEditionRow run twice is +// idempotent (no duplicate rows, no drift), and staleCatalogRowsWhere with an +// all-EDITION_2014 seeded list sweeps the orphaned NULL-edition rows the +// pre-retirement disciplines engine (34f5a4cf) left behind in a long-lived +// dev database — same idiom as granted-ability-fork-reseed.test.ts +// (seedDisciplines is inline in seed.ts, which self-invokes main() at module +// load and exports nothing a test can re-run directly, per that file's own +// header). +import { afterEach, describe, expect, it } from "vitest"; + +import { prisma } from "@/lib/core/prisma.js"; +import { upsertEditionRow } from "@/lib/rules/catalog-edition.js"; + +import { staleCatalogRowsWhere } from "../prune.js"; + +const DISCIPLINE_NAME = "Zzz Fork Reseed Discipline (#1503)"; +const ORPHAN_NAME = "Zzz Fork Reseed Discipline Orphan (#1503)"; + +afterEach(async () => { + await prisma.grantedAbility.deleteMany({ where: { name: { in: [DISCIPLINE_NAME, ORPHAN_NAME] } } }); +}); + +describe("seedDisciplines' upsert is idempotent (#1503)", () => { + it("running upsertEditionRow twice for the same (name, EDITION_2014) row updates in place, no duplicate", async () => { + const data = { + name: DISCIPLINE_NAME, + source: "discipline", + edition: "EDITION_2014" as const, + description: "v1", + minLevel: 3, + alwaysKnown: false, + costKind: "pool", + costPoolKey: "ki", + costBase: 1, + }; + let last; + for (let run = 0; run < 2; run += 1) { + last = await upsertEditionRow( + prisma.grantedAbility, + { name: DISCIPLINE_NAME, edition: "EDITION_2014" }, + { ...data, description: run === 0 ? "v1" : "v2" }, + { description: run === 0 ? "v1" : "v2" }, + ); + } + const rows = await prisma.grantedAbility.findMany({ where: { name: DISCIPLINE_NAME } }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(last!.id); + expect(rows[0].description).toBe("v2"); + expect(rows[0].edition).toBe("EDITION_2014"); + }); +}); + +describe("seedDisciplines' prune sweeps orphaned NULL-edition rows (#1503's own decision, 2026-08-03)", () => { + it("an all-EDITION_2014 seeded list drops a NULL-edition row of the same name (the 17 pre-retirement orphans)", async () => { + await prisma.grantedAbility.create({ + data: { name: ORPHAN_NAME, source: "discipline", description: "pre-retirement orphan", edition: null }, + }); + const retagged = await upsertEditionRow( + prisma.grantedAbility, + { name: ORPHAN_NAME, edition: "EDITION_2014" }, + { name: ORPHAN_NAME, source: "discipline", description: "current", edition: "EDITION_2014", minLevel: 3, alwaysKnown: false }, + { description: "current" }, + ); + + // Exactly what seedDisciplines passes: every DISCIPLINES row's OWN + // (always EDITION_2014) edition — never a flat null. Scoped to this + // file's own fixture name too (name: {in: [ORPHAN_NAME]}), same as + // granted-ability-fork-reseed.test.ts's ONLY_THIS_FILES_ROWS — without it + // this delete matches every OTHER source:"discipline" row not in this + // test's tiny seeded list too, i.e. the real 16-row catalog. + const seededAllEdition2014 = [{ identity: ORPHAN_NAME, edition: "EDITION_2014" as const }]; + const staleWhere = staleCatalogRowsWhere("name", seededAllEdition2014, { + source: "discipline", + name: { in: [ORPHAN_NAME] }, + }); + await prisma.grantedAbility.deleteMany({ where: staleWhere }); + + const surviving = await prisma.grantedAbility.findMany({ where: { name: ORPHAN_NAME } }); + expect(surviving).toHaveLength(1); + expect(surviving[0].id).toBe(retagged.id); + expect(surviving[0].edition).toBe("EDITION_2014"); + }); +}); + +describe("integration: the real seeded discipline catalog (#1503)", () => { + it("has exactly 16 source:\"discipline\" rows, all EDITION_2014, zero edition:NULL", async () => { + const rows = await prisma.grantedAbility.findMany({ where: { source: "discipline" } }); + expect(rows).toHaveLength(16); + expect(rows.every((r) => r.edition === "EDITION_2014")).toBe(true); + expect(rows.some((r) => r.edition === null)).toBe(false); + }); + + it("Elemental Attunement is NOT in the catalog (it's a DerivedFeature, not a pickable option)", async () => { + const row = await prisma.grantedAbility.findFirst({ where: { source: "discipline", name: "Elemental Attunement" } }); + expect(row).toBeNull(); + }); +}); diff --git a/backend/prisma/seed/__tests__/monk-2024-content.test.ts b/backend/prisma/seed/__tests__/monk-2024-content.test.ts index 016043da..71cbb081 100644 --- a/backend/prisma/seed/__tests__/monk-2024-content.test.ts +++ b/backend/prisma/seed/__tests__/monk-2024-content.test.ts @@ -37,9 +37,10 @@ const SHADOW = "monk-warrior-of-shadow"; const WAY_OF_SHADOW = "monk-way-of-shadow"; const ELEMENTS = "monk-warrior-of-the-elements"; const MERCY = "monk-warrior-of-mercy"; +const FOUR_ELEMENTS = "monk-way-of-the-four-elements"; -describe("Per-partition counts: base 17(2014)/18(2024); open hand (#1501) and shadow (#1502) each fork into two 4-row EDITION-EXCLUSIVE subclasses; elements 5, mercy 6 still identical for 2014/2024 pending #1503", () => { - it("counts match exactly (36 total 2014, 37 total 2024)", () => { +describe("Per-partition counts: base 17(2014)/18(2024); open hand (#1501), shadow (#1502), and the elements (#1503) each fork into two EDITION-EXCLUSIVE subclasses; mercy 6 is the one remaining subclass still identical for 2014/2024", () => { + it("counts match exactly (33 total 2014, 37 total 2024)", () => { const count = (slug: string | null, edition: Edition) => MONK_FEATURES.filter((r) => r.subclassSlug === slug && r.edition === edition).length; expect(count(BASE, "EDITION_2014")).toBe(17); expect(count(BASE, "EDITION_2024")).toBe(18); @@ -50,10 +51,10 @@ describe("Per-partition counts: base 17(2014)/18(2024); open hand (#1501) and sh expect(count(OPEN_HAND, "EDITION_2024")).toBe(4); expect(count(WAY_OPEN_HAND, "EDITION_2014")).toBe(4); expect(count(WAY_OPEN_HAND, "EDITION_2024")).toBe(0); - for (const edition of ["EDITION_2014", "EDITION_2024"] as const) { - expect(count(ELEMENTS, edition)).toBe(5); - expect(count(MERCY, edition)).toBe(6); - } + // Mercy is the one subclass with no 2014 fork yet — still shared/untagged, + // expanded to both editions. + expect(count(MERCY, "EDITION_2014")).toBe(6); + expect(count(MERCY, "EDITION_2024")).toBe(6); // Warrior of Shadow (2024) and Way of Shadow (2014, #1502) are now // DISTINCT slugs, each populated in exactly its own edition — the 2014/ // 2024 swap between them nets to zero on both totals below. @@ -61,12 +62,33 @@ describe("Per-partition counts: base 17(2014)/18(2024); open hand (#1501) and sh expect(count(SHADOW, "EDITION_2024")).toBe(4); expect(count(WAY_OF_SHADOW, "EDITION_2014")).toBe(4); expect(count(WAY_OF_SHADOW, "EDITION_2024")).toBe(0); - + // Warrior of the Elements (2024, retagged) and Way of the Four Elements + // (2014, brand new — no 2024 counterpart of its own, #1503) are also + // DISTINCT slugs. + expect(count(ELEMENTS, "EDITION_2014")).toBe(0); + expect(count(ELEMENTS, "EDITION_2024")).toBe(5); + expect(count(FOUR_ELEMENTS, "EDITION_2014")).toBe(2); + expect(count(FOUR_ELEMENTS, "EDITION_2024")).toBe(0); const total2014 = MONK_FEATURES.filter((r) => r.edition === "EDITION_2014").length; const total2024 = MONK_FEATURES.filter((r) => r.edition === "EDITION_2024").length; - expect(total2014).toBe(36); + expect(total2014).toBe(33); expect(total2024).toBe(37); - expect(MONK_FEATURES).toHaveLength(73); + expect(MONK_FEATURES).toHaveLength(70); + }); +}); + +describe("Way of the Four Elements (#1503): 2014-only, feature text carries the discipline-progression rule, not just the pool mechanism", () => { + it("Disciple of the Elements and Elemental Attunement exist for EDITION_2014 only", () => { + const r1 = row(FOUR_ELEMENTS, "Disciple of the Elements", "EDITION_2014"); + expect(r1.level).toBe(3); + expect(r1.description).toMatch(/PHB'14/); + const r2 = row(FOUR_ELEMENTS, "Elemental Attunement", "EDITION_2014"); + expect(r2.level).toBe(3); + expect(r2.description).toMatch(/PHB'14/); + }); + + it("carries no EDITION_2024 rows at all", () => { + expect(MONK_FEATURES.filter((r) => r.subclassSlug === FOUR_ELEMENTS && r.edition === "EDITION_2024")).toEqual([]); }); }); @@ -88,19 +110,19 @@ describe("Extra Attack (#1530): derivedStat/derivedStatTiers transcribed unchang }); describe("Elemental Attunement (#1686): the toggle descriptor block transcribed unchanged onto the literal subclass row", () => { - it("both editions carry the same toggle/cost/effectBuffs block", () => { - for (const edition of ["EDITION_2014", "EDITION_2024"] as const) { - const r = row(ELEMENTS, "Elemental Attunement", edition); - expect(r.resourceKey).toBe("elementalAttunement"); - expect(r.activationCost).toBe("free"); - expect(r.resolverKind).toBe("toggle"); - expect(r.costKind).toBe("pool"); - expect(r.costPoolKey).toBe("focus"); - expect(r.costBase).toBe(1); - expect(r.effectBuffs).toEqual([ - { key: "elementalAttunement", target: "elementalAttunement", modifier: 0, duration: "while-active" }, - ]); - } + // #1503 retagged this row's edition (EDITION_2024, alongside its Subclass + // row) — a single-edition check now, not a both-editions loop. + it("carries the toggle/cost/effectBuffs block", () => { + const r = row(ELEMENTS, "Elemental Attunement", "EDITION_2024"); + expect(r.resourceKey).toBe("elementalAttunement"); + expect(r.activationCost).toBe("free"); + expect(r.resolverKind).toBe("toggle"); + expect(r.costKind).toBe("pool"); + expect(r.costPoolKey).toBe("focus"); + expect(r.costBase).toBe(1); + expect(r.effectBuffs).toEqual([ + { key: "elementalAttunement", target: "elementalAttunement", modifier: 0, duration: "while-active" }, + ]); }); it("every OTHER row leaves every descriptor column undefined", () => { @@ -139,25 +161,29 @@ const ABILITY_SCORES = { // Integration-level proof (mirrors wizard-2024-content.test.ts's own // loadDbFeatureRows pattern): the REAL seeded rows, read through the REAL // derivation path, actually reach a serialized character's derived -// features — not just MONK_FEATURES' in-memory shape. Both editions read -// identically in this slice (no edition fork authored yet), so the proof is -// that a real L17 Warrior of the Elements monk sees all five subclass -// features under EITHER edition, and that a L2 monk (below every subclass -// gate) sees none. -describe("integration (#1675): a real seeded L17 Warrior of the Elements monk has all five subclass features, both editions", () => { - it("EDITION_2014 and EDITION_2024 resolve to the identical feature-name set", async () => { +// features — not just MONK_FEATURES' in-memory shape. #1503 retagged every +// Warrior of the Elements row EDITION_2024-only, so — unlike before that +// retag — the two editions no longer read identically: a real L17 EDITION_2024 +// Warrior of the Elements monk still sees all five subclass features, but the +// SAME subclass name under EDITION_2014 now resolves to none (a 2014 +// character's real equivalent is Way of the Four Elements, a different +// slug/name entirely — see the Way of the Four Elements integration test +// below). A L2 monk (below every subclass's grant level 3) sees none either way. +describe("integration (#1503): a real seeded L17 Warrior of the Elements monk has all five subclass features under EDITION_2024 only", () => { + it("EDITION_2024 resolves the full feature-name set; EDITION_2014 resolves none (its Subclass row no longer exists for that edition)", async () => { const featureRows = await loadDbFeatureRows("monk", "warrior of the elements"); const profBonus = proficiencyBonusForLevel(17); const expectedNames = ["Manipulate Elements", "Elemental Attunement", "Elemental Burst", "Stride of the Elements", "Elemental Epitome"]; - for (const edition of ["EDITION_2014", "EDITION_2024"] as const) { - const info = deriveResources("monk", "warrior of the elements", 17, ABILITY_SCORES, profBonus, featureRows, edition); - const subclassNames = (info?.features ?? []).filter((f) => f.source === "subclass").map((f) => f.name); - for (const name of expectedNames) { - expect(subclassNames, `${edition} missing ${name}`).toContain(name); - } + const info2024 = deriveResources("monk", "warrior of the elements", 17, ABILITY_SCORES, profBonus, featureRows, "EDITION_2024"); + const subclassNames2024 = (info2024?.features ?? []).filter((f) => f.source === "subclass").map((f) => f.name); + for (const name of expectedNames) { + expect(subclassNames2024, `EDITION_2024 missing ${name}`).toContain(name); } + + const info2014 = deriveResources("monk", "warrior of the elements", 17, ABILITY_SCORES, profBonus, featureRows, "EDITION_2014"); + expect((info2014?.features ?? []).filter((f) => f.source === "subclass")).toEqual([]); }); it("a L2 monk (below every subclass's grant level 3) has zero subclass features, both editions", async () => { @@ -170,6 +196,22 @@ describe("integration (#1675): a real seeded L17 Warrior of the Elements monk ha }); }); +// Way of the Four Elements' own integration proof — the 2014 slug/name pair. +describe("integration (#1503): a real seeded L17 Way of the Four Elements monk has both feature rows under EDITION_2014 only", () => { + it("EDITION_2014 resolves Disciple of the Elements + Elemental Attunement; EDITION_2024 resolves none", async () => { + const featureRows = await loadDbFeatureRows("monk", "way of the four elements"); + const profBonus = proficiencyBonusForLevel(17); + + const info2014 = deriveResources("monk", "way of the four elements", 17, ABILITY_SCORES, profBonus, featureRows, "EDITION_2014"); + const subclassNames2014 = (info2014?.features ?? []).filter((f) => f.source === "subclass").map((f) => f.name); + expect(subclassNames2014).toContain("Disciple of the Elements"); + expect(subclassNames2014).toContain("Elemental Attunement"); + + const info2024 = deriveResources("monk", "way of the four elements", 17, ABILITY_SCORES, profBonus, featureRows, "EDITION_2024"); + expect((info2024?.features ?? []).filter((f) => f.source === "subclass")).toEqual([]); + }); +}); + describe("#1500: the 2014 base-class row count reflects real SRD 5.1 content (17, not 2024's 18)", () => { it("the 2014 base-class row count is 17", () => { expect(MONK_FEATURES.filter((r) => r.subclassSlug === null && r.edition === "EDITION_2014")).toHaveLength(17); diff --git a/backend/prisma/seed/disciplines.ts b/backend/prisma/seed/disciplines.ts new file mode 100644 index 00000000..0ea21a44 --- /dev/null +++ b/backend/prisma/seed/disciplines.ts @@ -0,0 +1,292 @@ +// ── Elemental Discipline catalog (Way of the Four Elements, 2014-only) ────── +// PHB'14 pp. 80–81 — Way of the Four Elements is NOT in SRD 5.1, so every +// description below is transcribed from PHB'14 knowledge, not the SRD. Each +// row is a GrantedAbility (source "discipline", edition "EDITION_2014") +// consumed by the generic subclass-choice picker (GET +// /api/subclass-choices/discipline) and the discipline cast handler +// (lib/classes/disciplines.ts). 16 rows, not 17: Elemental Attunement is +// PHB'14 p.80's free, always-known, uncapped discipline — it is a +// DerivedFeature (monk-features.ts) plus a DERIVED_ACTIONS reminder +// (actions.ts), never a catalog row a player picks or forgets. +// +// Structural reference ONLY for the costKind/costBase/costPerStep + +// EffectSpec column shapes: `34f5a4cf^:backend/prisma/seed/disciplines.ts` +// (repo owner decision, 2026-07-29) — its RULES VALUES are not trustworthy +// (no page citations, one citing the wrong book). Two known errors it +// carried are corrected here: Fist of Four Thunders casts Thunderwave, whose +// damage is 2d8 (not 3d8), and Eternal Mountain Defense's own text states +// "you must be a 13th-level monk to use this discipline" (not 17th) — a +// PHB errata-shaped anomaly: it is offered among the 11th-level tier's +// choices (alongside Mist Stance/Ride the Wind/Flames of the Phoenix, whose +// 4-ki cost a monk can pay from level 3 onward) but its own 5-ki cost isn't +// payable until level 13 (PHB'14 p.80's Elemental Disciplines table caps a +// single cast at 4 ki through level 12), so PHB'14 states the 13th-level +// gate directly on the discipline's own text rather than leaving it an +// unusable pick for two levels. +// +// The per-cast ki cap (min(6, 2 + floor((monkLevel-1)/4)) -> 2/3/4/5/6 at +// L3/L5/L9/L13/L17) is enforced in lib/classes/disciplines.ts +// (maxKiPerDiscipline), not here — this module carries no logic at all +// (a pure content array only, per scripts/check-seed-data-modules.sh). +// +// costBase for every spell-equivalent discipline is exactly one more than +// the underlying spell's level (Thunderwave 1st -> 2 ki, Shatter 2nd -> 3 ki, +// Fireball 3rd -> 4 ki, Stoneskin 4th -> 5 ki, Cone of Cold 5th -> 6 ki) — +// PHB'14 p.80's own worked example ("spend 3 ki points to cast [Burning +// Hands] as a 2nd-level spell" from Sweeping Cinder Strike's 2-ki base). +// costPerStep is set only where the discipline's own text allows spending +// EXTRA ki to add damage dice (Fangs of the Fire Snake, Fist of Unbroken Air, +// Water Whip, Gong of the Summit, Sweeping Cinder Strike) — the others cast a +// spell at a fixed level with no upcast option through the discipline itself. +import type { SeedEdition } from "./edition.js"; + +export interface DisciplineSeed { + name: string; + description: string; + minLevel: number; + alwaysKnown?: boolean; + edition: SeedEdition; + costKind: "pool" | "none"; + costPoolKey?: string; + costBase?: number; + costPerStep?: number; + effectKind?: "damage"; + effectDiceCount?: number; + effectDiceFaces?: number; + damageType?: string; + attackType?: "attack" | "save"; + saveAbility?: string; + saveEffect?: "half"; +} + +export const DISCIPLINES: DisciplineSeed[] = [ + { + name: "Fangs of the Fire Snake", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 1, + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 1, + effectDiceFaces: 10, + damageType: "fire", + attackType: "attack", + description: + "When you use the Attack action on your turn, spend 1 ki to cause your unarmed strikes to deal fire damage instead of bludgeoning and extend your reach by 10 ft for that action. When you hit with one, spend 1 ki to deal an extra 1d10 fire damage (plus 1d10 per additional ki spent, up to your per-cast ki cap). PHB'14 p.81.", + }, + { + name: "Fist of Four Thunders", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 2, + effectKind: "damage", + effectDiceCount: 2, + effectDiceFaces: 8, + damageType: "thunder", + attackType: "save", + saveAbility: "constitution", + saveEffect: "half", + description: + "Spend 2 ki to cast thunderwave: each creature in a 15-ft cube from you makes a Constitution save, taking 2d8 thunder damage and being pushed 10 ft on a failure, half damage and no push on a success. PHB'14 p.81.", + }, + { + name: "Fist of Unbroken Air", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 2, + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 10, + damageType: "bludgeoning", + attackType: "save", + saveAbility: "strength", + description: + "As an action, spend 2 ki: choose a creature within 30 ft. It makes a Strength save, taking 3d10 bludgeoning damage (plus 1d10 per additional ki spent, up to your per-cast ki cap), being pushed 20 ft away, and knocked prone on a failure; on a success it takes half damage and suffers neither push nor prone. PHB'14 p.81.", + }, + { + name: "Rush of the Gale Spirits", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 2, + saveAbility: "strength", + description: + "Spend 2 ki to cast gust of wind: a 60-ft line of strong wind blows from you for up to 1 minute (concentration); each creature that starts its turn in the line makes a Strength save or is pushed 15 ft away. PHB'14 p.81.", + }, + { + name: "Shape the Flowing River", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 1, + description: + "As an action, spend 1 ki to freeze, melt, or otherwise reshape an area of water or ice up to 30 ft on a side within 120 ft, changing its depth, shape, or transparency, and optionally move it up to 5 ft. This can't damage a creature or object. PHB'14 p.81.", + }, + { + name: "Sweeping Cinder Strike", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 2, + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 6, + damageType: "fire", + attackType: "save", + saveAbility: "dexterity", + saveEffect: "half", + description: + "Spend 2 ki to cast burning hands: each creature in a 15-ft cone makes a Dexterity save, taking 3d6 fire damage (plus 1d6 per additional ki spent, up to your per-cast ki cap) on a failure, half on a success. PHB'14 p.81.", + }, + { + name: "Water Whip", + minLevel: 3, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 2, + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 10, + damageType: "bludgeoning", + attackType: "save", + saveAbility: "dexterity", + description: + "As an action, spend 2 ki: choose a creature you can see within 30 ft. It makes a Dexterity save, taking 3d10 bludgeoning damage (plus 1d10 per additional ki spent, up to your per-cast ki cap) and — your choice — being knocked prone or pulled up to 25 ft toward you on a failure; on a success it takes half damage and suffers neither. PHB'14 p.81.", + }, + { + name: "Clench of the North Wind", + minLevel: 6, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 3, + saveAbility: "wisdom", + description: + "Spend 3 ki to cast hold person (no higher-level upcast): one humanoid within range makes a Wisdom save (repeating it at the end of each of its turns) or is paralyzed for up to 1 minute (concentration). PHB'14 p.81.", + }, + { + name: "Gong of the Summit", + minLevel: 6, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 3, + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 8, + damageType: "thunder", + attackType: "save", + saveAbility: "constitution", + saveEffect: "half", + description: + "Spend 3 ki to cast shatter: each creature in a 10-ft-radius sphere makes a Constitution save, taking 3d8 thunder damage (plus 1d8 per additional ki spent, up to your per-cast ki cap) on a failure, half on a success. PHB'14 p.81.", + }, + { + name: "Mist Stance", + minLevel: 11, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 4, + description: + "Spend 4 ki to cast gaseous form on yourself, becoming a misty cloud for up to 1 hour (concentration). PHB'14 p.81.", + }, + { + name: "Ride the Wind", + minLevel: 11, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 4, + description: + "Spend 4 ki to cast fly on yourself, gaining a 60-ft flying speed for up to 10 minutes (concentration). PHB'14 p.81.", + }, + { + name: "Flames of the Phoenix", + minLevel: 11, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 4, + effectKind: "damage", + effectDiceCount: 8, + effectDiceFaces: 6, + damageType: "fire", + attackType: "save", + saveAbility: "dexterity", + saveEffect: "half", + description: + "Spend 4 ki to cast fireball: each creature in a 20-ft-radius sphere within 150 ft makes a Dexterity save, taking 8d6 fire damage on a failure, half on a success. PHB'14 p.81.", + }, + { + name: "Breath of Winter", + minLevel: 17, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 6, + effectKind: "damage", + effectDiceCount: 8, + effectDiceFaces: 8, + damageType: "cold", + attackType: "save", + saveAbility: "constitution", + saveEffect: "half", + description: + "Spend 6 ki to cast cone of cold: each creature in a 60-ft cone makes a Constitution save, taking 8d8 cold damage on a failure, half on a success. PHB'14 p.81.", + }, + { + name: "Eternal Mountain Defense", + minLevel: 13, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 5, + description: + // Offered among the 11th-level tier's picks, but PHB'14's own text on + // this discipline states the 13th-level gate directly — see this + // module's header comment for why minLevel is 13, not 11 or 17. + "You must be at least a 13th-level monk to use this discipline. Spend 5 ki to cast stoneskin on yourself, resisting nonmagical bludgeoning, piercing, and slashing damage for up to 1 hour (concentration). PHB'14 p.81.", + }, + { + name: "River of Hungry Flame", + minLevel: 17, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 5, + effectKind: "damage", + effectDiceCount: 5, + effectDiceFaces: 8, + damageType: "fire", + attackType: "save", + saveAbility: "dexterity", + saveEffect: "half", + description: + "Spend 5 ki to cast wall of fire: a wall of flame up to 1 minute (concentration) deals 5d8 fire damage to a creature on entering or ending its turn there (Dexterity save for half). PHB'14 p.81.", + }, + { + name: "Wave of Rolling Earth", + minLevel: 17, + edition: "EDITION_2014", + costKind: "pool", + costPoolKey: "ki", + costBase: 6, + description: + "Spend 6 ki to cast wall of stone, raising a solid wall of rock for up to 10 minutes (concentration) unless made permanent. PHB'14 p.81.", + }, +]; diff --git a/backend/prisma/seed/monk-features.ts b/backend/prisma/seed/monk-features.ts index da835abf..af8698d4 100644 --- a/backend/prisma/seed/monk-features.ts +++ b/backend/prisma/seed/monk-features.ts @@ -12,30 +12,40 @@ // expand() below is pure content assembly, not seeding logic. // // SCOPE (#1675 transport, #1500 base-class rewrite, #1501 Open Hand fork, -// #1502 Shadow fork): #1675 moved every row here as a byte-identical -// transcription of what lib/classes/monk.ts's MONK_FEATURES / -// WARRIOR_OF_*_FEATURES said, both editions sharing one row. #1500 rewrites -// the 18 BASE-CLASS rows (MONK_BASE_RAW below) from real SRD 5.1 / PHB'14 -// text — a genuine content fork per feature, not a retag: several 2014 -// features have no 2024 name at all (Uncanny Metabolism/Heightened -// Focus/Self-Restoration/Perfect Focus are 2024-only; Stillness of -// Mind/Purity of Body/Tongue of the Sun and Moon/Timeless Body/Empty -// Body/Perfect Self are 2014-only), so the 2014 partition is 17 rows against -// the 2024 partition's 18 (monk-2024-content.test.ts's per-partition count -// pins this exactly). #1501 forks Warrior of the Open Hand into two SEPARATE -// subclasses — "Warrior of the Open Hand" stays EDITION_2024-only (its four -// rows tagged in that same commit) and "Way of the Open Hand" is authored -// fresh as EDITION_2014-only (SRD 5.1's only monastic tradition) — rather -// than one slug hosting both editions' text, since the 2014 and 2024 names -// genuinely differ (monk.ts's two SubclassDefinition entries are the same -// split). #1502 forks Warrior of Shadow the same way: its four rows are now -// tagged EDITION_2024 (they still exist under monk-warrior-of-shadow's slug, -// just no longer a both-editions transcription), and a NEW Way of Shadow -// subclass (monk-way-of-shadow, a DISTINCT slug) carries its own four -// EDITION_2014 rows, real PHB'14 pp.79-80 content (not in SRD 5.1). The one -// remaining 2024-only subclass (Warrior of the Elements) is still -// untouched — no 2014 slug exists for it yet (#1503's later slice), so its -// rows stay a byte-identical transcription pending that. +// #1502 Shadow fork, #1503 Warrior of the Elements retag + Way of the Four +// Elements): #1675 moved every row here as a byte-identical transcription of +// what lib/classes/monk.ts's MONK_FEATURES / WARRIOR_OF_*_FEATURES said, both +// editions sharing one row. #1500 rewrites the 18 BASE-CLASS rows +// (MONK_BASE_RAW below) from real SRD 5.1 / PHB'14 text — a genuine content +// fork per feature, not a retag: several 2014 features have no 2024 name at +// all (Uncanny Metabolism/Heightened Focus/Self-Restoration/Perfect Focus are +// 2024-only; Stillness of Mind/Purity of Body/Tongue of the Sun and +// Moon/Timeless Body/Empty Body/Perfect Self are 2014-only), so the 2014 +// partition is 17 rows against the 2024 partition's 18 +// (monk-2024-content.test.ts's per-partition count pins this exactly). +// #1501 forks Warrior of the Open Hand into two SEPARATE subclasses — +// "Warrior of the Open Hand" stays EDITION_2024-only (its four rows tagged in +// that same commit) and "Way of the Open Hand" is authored fresh as +// EDITION_2014-only (SRD 5.1's only monastic tradition) — rather than one +// slug hosting both editions' text, since the 2014 and 2024 names genuinely +// differ (monk.ts's two SubclassDefinition entries are the same split). +// #1502 forks Warrior of Shadow the same way: its four rows are now tagged +// EDITION_2024 (they still exist under monk-warrior-of-shadow's slug, just no +// longer a both-editions transcription), and a NEW Way of Shadow subclass +// (monk-way-of-shadow, a DISTINCT slug) carries its own four EDITION_2014 +// rows, real PHB'14 pp.79-80 content (not in SRD 5.1). #1503 retags Warrior +// of the Elements EDITION_2024 (its four rows, same "no longer shared" shape +// as #1501/#1502's retags) and adds Way of the Four Elements +// (monk-way-of-the-four-elements) as a brand-new 2014-only slug with NO 2024 +// counterpart at all — Warrior of the Elements is a from-scratch PHB'24 +// rebuild, not this subclass under a different edition tag, so there was +// nothing to retag it FROM; see resolveSubclassId, seed-class-features.ts for +// why a retagged Subclass row forces its ClassFeature rows to fork too (a +// shared/untagged row has nothing to resolve against once its Subclass row +// stops being NULL-edition). Warrior of Mercy is the one remaining +// 2024-only subclass still untouched — no 2014 monk subclass slug exists for +// it yet — so its rows stay a byte-identical transcription shared across +// both editions. // monk.ts keeps its resourceFn for the ki/focus pool (now edition-forked, see // monkPoolKey) and every subclass resourceFn unchanged (except Way of the // Open Hand, which needs none — see monk.ts's own comment). @@ -106,6 +116,7 @@ interface RawMonkFeature { } function expand(raw: RawMonkFeature): ClassFeatureSeedRow[] { + // fallow-ignore-next-line code-duplication -- this expand()/base-object shape (and the MONK_BASE_RAW array of {subclassSlug,name,level,description,...} entries below it) intentionally mirrors barbarian-features.ts's own expand()/RAW array — each class's literal seed module is authored independently by existing convention (fighter-features.ts, wizard-features.ts, ...), never a shared base type; see wizard-features.ts's own identical suppression for the RawWizardFeature interface. const base: Omit = { className: "Monk", // fallow-ignore-next-line code-duplication -- expand()'s field-by-field copy intentionally mirrors fighter-features.ts's/wizard-features.ts's own expand() (every Raw*Feature -> ClassFeatureSeedRow adapter across this file family repeats this shape by convention, never a shared helper) @@ -693,18 +704,24 @@ const WARRIOR_OF_MERCY_RAW: RawMonkFeature[] = [ // Elements + Elemental Attunement at L3, Elemental Burst at L6, Stride of // the Elements at L11, and the Elemental Epitome capstone at L17. Elemental // Attunement is modeled as a while-active buff + two Focus-spending session -// actions (toggle + Elemental Burst) — see warrior-of-elements.ts. +// actions (toggle + Elemental Burst) — see warrior-of-elements.ts. Every row +// tagged EDITION_2024 (#1503) — see the file header's note on why this +// subclass, unlike Open Hand/Shadow/Mercy, can't stay untagged/shared: its +// Subclass row forked the moment Way of the Four Elements (its real 2014 +// predecessor) was authored with its own slug. const WARRIOR_OF_THE_ELEMENTS_RAW: RawMonkFeature[] = [ { subclassSlug: slug("monk-warrior-of-the-elements"), name: "Manipulate Elements", level: 3, + edition: "EDITION_2024", description: "You know the Elementalism cantrip. Wisdom is your spellcasting ability for it.", }, { subclassSlug: slug("monk-warrior-of-the-elements"), name: "Elemental Attunement", level: 3, + edition: "EDITION_2024", description: "At the start of your turn, you can expend 1 Focus Point (no action) to imbue yourself with elemental energy for 10 minutes (or until you're Incapacitated). While attuned: your Unarmed Strike reach increases by 10 ft; and once per Unarmed Strike hit you can deal Acid, Cold, Fire, Lightning, or Thunder damage instead of the normal type — when you do, you can force the target to make a Strength saving throw (your focus save DC), moving it up to 10 ft in a direction of your choice on a failure.", // #1686: the TOGGLE half only — activating/ending the buff that gates @@ -736,6 +753,7 @@ const WARRIOR_OF_THE_ELEMENTS_RAW: RawMonkFeature[] = [ subclassSlug: slug("monk-warrior-of-the-elements"), name: "Elemental Burst", level: 6, + edition: "EDITION_2024", description: "As a Magic action, you can expend 2 Focus Points to create a 20-foot-radius sphere of elemental energy centered on a point within 120 ft. Choose Acid, Cold, Fire, Lightning, or Thunder. Each creature in the sphere makes a Dexterity saving throw (your focus save DC), taking damage equal to three rolls of your Martial Arts die of the chosen type on a failure, or half as much on a success.", }, @@ -743,30 +761,61 @@ const WARRIOR_OF_THE_ELEMENTS_RAW: RawMonkFeature[] = [ subclassSlug: slug("monk-warrior-of-the-elements"), name: "Stride of the Elements", level: 11, + edition: "EDITION_2024", description: "While your Elemental Attunement is active, you have a Fly Speed and a Swim Speed each equal to your Speed.", }, { subclassSlug: slug("monk-warrior-of-the-elements"), name: "Elemental Epitome", level: 17, + edition: "EDITION_2024", description: "While your Elemental Attunement is active you gain: Resistance to Acid, Cold, Fire, Lightning, or Thunder damage (choose one at the start of each of your turns); Destructive Stride (when you use Step of the Wind, your Speed increases by 20 ft that turn, and the first creature you move within 5 ft of takes one roll of your Martial Arts die of your chosen resistance type); and Empowered Strikes (once per turn, one Unarmed Strike deals an extra Martial Arts die of your chosen resistance type on a hit).", }, ]; +// ---- Way of the Four Elements (2014-only, #1503) — PHB'14 pp. 78, 80-81, --- +// ---- not in SRD 5.1 --------------------------------------------------------- +// Disciple of the Elements (L3) is the mechanism feature: it names the +// ki-fueled discipline menu, the known-discipline progression (1/2/3/4 chosen +// disciplines at L3/6/11/17, i.e. 2/3/4/5 total with the always-known +// Elemental Attunement), and the learn-a-new/swap-one-known rule. The actual +// catalog is disciplines.ts (16 rows, source "discipline") plus +// monk.ts's `choices` declaration (#899) — this row is player-facing text +// only, no mechanics of its own. Elemental Attunement (also L3) is the one +// ALWAYS-known discipline (free, uncapped) — its own row here is flavor text; +// the reminder action + level gate are a DERIVED_ACTIONS row (actions.ts), +// matching every other monk subclass action (#1315). +const WAY_OF_THE_FOUR_ELEMENTS_RAW: RawMonkFeature[] = [ + { + subclassSlug: slug("monk-way-of-the-four-elements"), + name: "Disciple of the Elements", + level: 3, + edition: "EDITION_2014", + description: + "You learn magical disciplines that harness the power of the four elements. You know Elemental Attunement plus one other elemental discipline of your choice, learning one more at 6th, 11th, and 17th level (2/3/4/5 known total). A discipline requires you to spend ki points each time you use it, and some disciplines require you to reach a specified monk level before you can use them. Whenever you learn a new elemental discipline, you can also replace one you already know with a different discipline. PHB'14 pp. 78, 80.", + }, + { + subclassSlug: slug("monk-way-of-the-four-elements"), + name: "Elemental Attunement", + level: 3, + edition: "EDITION_2014", + description: + "You always know this elemental discipline, and it doesn't count against the number of elemental disciplines you know. As an action, you can briefly control elemental forces within 30 ft of you, causing one of the following effects: create a harmless, sensory elemental effect; instantaneously light or snuff out a candle, torch, or small campfire; chill or warm up to 1 pound of nonliving material for up to 1 hour; or shape a small amount of nonliving earth, fire, water, or mist for up to 1 minute. PHB'14 p.80.", + }, +]; + // The full Monk seed family: base class (17 EDITION_2014 rows / 18 // EDITION_2024 rows, #1500) + Way of the Open Hand (4 EDITION_2014-only // rows, #1501) + Warrior of the Open Hand (4 EDITION_2024-only rows, #1501) // + Way of Shadow (4 EDITION_2014-only rows, #1502) + Warrior of Shadow (4 -// EDITION_2024-only rows, #1502) + the two still-untagged 2024-only -// subclasses expanded to both editions pending #1503 (Elements 5, Mercy 6 = -// 11 features x 2 editions = 22 rows) = 36 EDITION_2014 + 37 EDITION_2024 = -// 73 rows total (monk-2024-content.test.ts pins the per-partition counts -// exactly — each fork's 2014/2024 row swap is a wash: 4 rows move from the -// shared-count column to their own edition-exclusive one on each side, -// netting to zero on both totals). -// Concatenated into class-features.ts's CLASS_FEATURES the same way every -// other literal class's export is. +// EDITION_2024-only rows, #1502) + Warrior of Mercy (6 features, still +// shared/untagged — no 2014 fork exists yet) + Warrior of the Elements (5 +// EDITION_2024-only rows, #1503's retag) + Way of the Four Elements (2 +// EDITION_2014-only rows, #1503) = 33 EDITION_2014 + 37 EDITION_2024 = 70 +// rows total (monk-2024-content.test.ts pins the per-partition counts +// exactly). Concatenated into class-features.ts's CLASS_FEATURES the same +// way every other literal class's export is. export const MONK_FEATURES: ClassFeatureSeedRow[] = [ ...MONK_BASE_RAW.flatMap(expand), ...WARRIOR_OF_THE_OPEN_HAND_RAW.flatMap(expand), @@ -775,4 +824,5 @@ export const MONK_FEATURES: ClassFeatureSeedRow[] = [ ...WAY_OF_SHADOW_RAW.flatMap(expand), ...WARRIOR_OF_MERCY_RAW.flatMap(expand), ...WARRIOR_OF_THE_ELEMENTS_RAW.flatMap(expand), + ...WAY_OF_THE_FOUR_ELEMENTS_RAW.flatMap(expand), ]; diff --git a/backend/prisma/seed/subclasses.ts b/backend/prisma/seed/subclasses.ts index d0533c43..38b621d8 100644 --- a/backend/prisma/seed/subclasses.ts +++ b/backend/prisma/seed/subclasses.ts @@ -239,6 +239,20 @@ export const SUBCLASSES: SubclassSeed[] = [ description: "You wield the elements of air, earth, fire, and water. Manipulate Elements grants the Elementalism cantrip, and Elemental Attunement lets you spend 1 Focus Point to imbue yourself for 10 minutes — extending your Unarmed Strike reach and letting your strikes deal elemental damage that shoves foes. Elemental Burst (level 6) unleashes a 20-ft sphere for three Martial Arts dice, Stride of the Elements (level 11) grants flight and swimming while attuned, and Elemental Epitome (level 17) adds elemental resistance, a destructive stride, and empowered strikes.", slug: "monk-warrior-of-the-elements", + // Retagged EDITION_2024 (#1503, alongside authoring the 2014 predecessor + // below) — a from-scratch PHB'24 rebuild (Elementalism cantrip + a + // Focus-fuelled buff toggle + Elemental Burst/Strike), not a retab of Way + // of the Four Elements' discipline menu. Was untagged/shared since #1246; + // this is the first PR to author the 2014 half, so the two must fork. + edition: "EDITION_2024", + }, + { + className: "Monk", + name: "Way of the Four Elements", + description: + "You harness the elements of air, earth, fire, and water through Elemental Disciplines fueled by ki. You always know Elemental Attunement (a free, at-will minor elemental effect) plus one other discipline at 3rd level, learning one more at 6th, 11th, and 17th — from a menu that includes reshaping Thunderwave, Burning Hands, and Fireball into your own strikes, or casting Fly, Gaseous Form, and Stoneskin on yourself. Whenever you learn a new discipline, you may replace one you already know with a different one. Not in SRD 5.1 — PHB'14 pp. 78, 80–81.", + slug: "monk-way-of-the-four-elements", + edition: "EDITION_2014", }, { className: "Monk", diff --git a/backend/src/lib/activity/events.ts b/backend/src/lib/activity/events.ts index 1c86f9c5..f71a8d05 100644 --- a/backend/src/lib/activity/events.ts +++ b/backend/src/lib/activity/events.ts @@ -81,6 +81,13 @@ export type EventType = // toggle now logs through the generic buffApplied/buffCleared/ // spendResource event types (like Rage) instead of its own bespoke one. | "castElementalBurst" + // Way of the Four Elements (2014, #1503) — cast a known elemental + // discipline. The only one of the retired 2014 discipline events + // (learnDiscipline/forgetDiscipline/swapDiscipline/disciplinesReconciled/ + // castDiscipline, #1247/34f5a4cf) that returns: learn/forget ride the + // generic learnSubclassChoice/forgetSubclassChoice events, and the reconcile + // rides subclassChoicesReconciled — no bespoke discipline-only twins. + | "castDiscipline" | "elementalStrike" | "learnToolProficiency" | "forgetToolProficiency" diff --git a/backend/src/lib/classes/__tests__/__snapshots__/class-features-snapshot.test.ts.snap b/backend/src/lib/classes/__tests__/__snapshots__/class-features-snapshot.test.ts.snap index 52d6dfc6..d36aa066 100644 --- a/backend/src/lib/classes/__tests__/__snapshots__/class-features-snapshot.test.ts.snap +++ b/backend/src/lib/classes/__tests__/__snapshots__/class-features-snapshot.test.ts.snap @@ -9346,6 +9346,679 @@ exports[`deriveResources snapshot — pins output for every class/subclass acros ] `; +exports[`deriveResources snapshot — pins output for every class/subclass across all 20 levels > monk / way of the four elements 1`] = ` +[ + { + "info": { + "resources": [], + }, + "level": 1, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 11. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 6, + "flatBonus": 2, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 2, + }, + ], + }, + "level": 2, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 11. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 6, + "flatBonus": 3, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 3, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 1, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 3, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 11. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 6, + "flatBonus": 4, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 4, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 1, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 4, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 12. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 5, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 5, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 1, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 5, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 12. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 6, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 6, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 2, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 6, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 12. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 7, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 7, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 2, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 7, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 12. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 8, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 8, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 2, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 8, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 13. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 9, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 9, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 2, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 9, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 13. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 8, + "flatBonus": 10, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 10, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 2, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 10, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 13. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 11, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 11, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 11, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 13. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 12, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 12, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 12, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 14. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 13, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 13, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 13, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 14. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 14, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + ], + "recharge": "short-or-long", + "total": 14, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 14, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 14. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 15, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 15, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 15, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 14. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 10, + "flatBonus": 16, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 16, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 3, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 16, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 15. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 12, + "flatBonus": 17, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 17, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 4, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 17, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 15. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 12, + "flatBonus": 18, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 18, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 4, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 18, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 15. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 12, + "flatBonus": 19, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 19, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 4, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 19, + }, + { + "info": { + "resources": [ + { + "description": "Fuel focus features: Flurry of Blows (1 focus), Patient Defense (free, or 1 focus for more), Step of the Wind (free, or 1 focus for more), and subclass abilities. Focus save DC 15. Regain all focus on a short or long rest.", + "key": "focus", + "label": "Focus Points", + "onInitiative": [ + { + "amount": "all", + "bonusHeal": { + "dieFaces": 12, + "flatBonus": 20, + "sourceName": "Uncanny Metabolism", + }, + "id": "uncannyMetabolism", + "oncePerLongRest": true, + }, + { + "amount": 4, + "id": "perfectFocus", + }, + ], + "recharge": "short-or-long", + "total": 20, + }, + ], + "subclassChoices": [ + { + "catalogSource": "discipline", + "count": 4, + "key": "fourElementsDisciplines", + "label": "Elemental Disciplines", + }, + ], + }, + "level": 20, + }, +] +`; + exports[`deriveResources snapshot — pins output for every class/subclass across all 20 levels > monk / way of the open hand 1`] = ` [ { @@ -20064,6 +20737,91 @@ exports[`resolveClassDie snapshot — every class-die pool across all classes/su ] `; +exports[`resolveClassDie snapshot — every class-die pool across all classes/subclasses > monk / way of the four elements 1`] = ` +[ + { + "dice": {}, + "level": 1, + }, + { + "dice": {}, + "level": 2, + }, + { + "dice": {}, + "level": 3, + }, + { + "dice": {}, + "level": 4, + }, + { + "dice": {}, + "level": 5, + }, + { + "dice": {}, + "level": 6, + }, + { + "dice": {}, + "level": 7, + }, + { + "dice": {}, + "level": 8, + }, + { + "dice": {}, + "level": 9, + }, + { + "dice": {}, + "level": 10, + }, + { + "dice": {}, + "level": 11, + }, + { + "dice": {}, + "level": 12, + }, + { + "dice": {}, + "level": 13, + }, + { + "dice": {}, + "level": 14, + }, + { + "dice": {}, + "level": 15, + }, + { + "dice": {}, + "level": 16, + }, + { + "dice": {}, + "level": 17, + }, + { + "dice": {}, + "level": 18, + }, + { + "dice": {}, + "level": 19, + }, + { + "dice": {}, + "level": 20, + }, +] +`; + exports[`resolveClassDie snapshot — every class-die pool across all classes/subclasses > monk / way of the open hand 1`] = ` [ { diff --git a/backend/src/lib/classes/__tests__/actions.test.ts b/backend/src/lib/classes/__tests__/actions.test.ts index 0afda2ff..7aad41f7 100644 --- a/backend/src/lib/classes/__tests__/actions.test.ts +++ b/backend/src/lib/classes/__tests__/actions.test.ts @@ -1500,14 +1500,19 @@ describe("subclass gate resolves via slug — FK preferred, exact name as fallba // maintained table). Still exercises the name-fallback path at runtime: for // each slug, resolve its accepted NAME via SUBCLASS_IDENTITY and call // through `at()`, so this is the same mechanism the FK path uses, minus the FK. - // elementalAttunement is deliberately absent from its subclass's list here — - // it's row-driven (#1686) and unreachable through the bare at() this test - // uses; elementalBurst alone still proves the slug match. Each entry now - // carries its OWN `edition` (#1501/#1502): "monk-way-of-the-open-hand" and - // "monk-way-of-shadow" are each EDITION_2014-only, so a blanket - // EDITION_2024 loop (the shape before these two slices) would wrongly - // report their rows unreachable — the edition gate excludes them, not the - // subclass gate this test means to exercise. + // elementalAttunement is deliberately absent from the 2024 Warrior of the + // Elements/Warrior of Shadow/Warrior of the Open Hand lists here — it's + // row-driven (#1686) and unreachable through the bare at() this test uses; + // elementalBurst alone still proves the slug match for that subclass. The + // 2014 Way of the Four Elements elementalAttunement is a PLAIN + // DERIVED_ACTIONS reminder row (#1503, not row-driven), so it IS reachable + // and listed. Each entry carries its OWN `edition` (#1501/#1502/#1503): + // "monk-way-of-the-open-hand", "monk-way-of-shadow", and + // "monk-way-of-the-four-elements" are each EDITION_2014-only, so a blanket + // EDITION_2024 loop (the shape before these three slices) would wrongly + // report their rows unreachable — `at()`'s own default (EDITION_2024) + // would silently exclude them, the exact same-key-different-edition trap + // #1499 anticipated. const MONK_SUBCLASS_GRANT_KEYS: Record< Extract, { edition: "EDITION_2014" | "EDITION_2024"; keys: string[] } @@ -1516,6 +1521,7 @@ describe("subclass gate resolves via slug — FK preferred, exact name as fallba "monk-warrior-of-the-elements": { edition: "EDITION_2024", keys: ["elementalBurst"] }, "monk-warrior-of-the-open-hand": { edition: "EDITION_2024", keys: ["wholenessOfBody", "fleetStep"] }, "monk-warrior-of-mercy": { edition: "EDITION_2024", keys: ["handOfHealing", "handOfHealingFlurry"] }, + "monk-way-of-the-four-elements": { edition: "EDITION_2014", keys: ["elementalAttunement", "castDiscipline"] }, "monk-way-of-the-open-hand": { edition: "EDITION_2014", keys: ["wholenessOfBodyAction", "tranquility"] }, "monk-way-of-shadow": { edition: "EDITION_2014", keys: ["shadowArts", "shadowStep", "cloakOfShadows", "opportunist"] }, }; diff --git a/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts b/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts index 46ad319c..81b2141c 100644 --- a/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts +++ b/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts @@ -48,7 +48,7 @@ export const CLASS_SUBCLASSES: Record = { cleric: [undefined, "life domain", "trickery domain"], druid: [undefined, "circle of the land", "circle of the moon"], fighter: [undefined, "battle master", "champion", "eldritch knight"], - monk: [undefined, "warrior of the open hand", "way of the open hand", "warrior of shadow", "way of shadow", "warrior of the elements", "warrior of mercy"], + monk: [undefined, "warrior of the open hand", "way of the open hand", "warrior of shadow", "way of shadow", "warrior of the elements", "way of the four elements", "warrior of mercy"], paladin: [undefined, "oath of devotion", "oath of the ancients", "oath of vengeance"], ranger: [undefined, "hunter", "beast master"], rogue: [undefined, "arcane trickster", "assassin", "thief"], diff --git a/backend/src/lib/classes/__tests__/disciplines.test.ts b/backend/src/lib/classes/__tests__/disciplines.test.ts new file mode 100644 index 00000000..63c7a9c1 --- /dev/null +++ b/backend/src/lib/classes/__tests__/disciplines.test.ts @@ -0,0 +1,76 @@ +// Unit tests for the Way of the Four Elements discipline rules module +// (2014-only, #1503) — the pure functions only; the cast transaction is +// covered end-to-end in routes/character/__tests__/disciplines-cast.test.ts. +import { describe, expect, it } from "vitest"; + +import { disciplineEffectSpec, maxKiPerDiscipline } from "@/lib/classes/disciplines.js"; + +// PHB'14 p.80's Elemental Disciplines table: max ki spendable on ONE cast, +// by monk level. min(6, 2 + floor((monkLevel-1)/4)). +describe("maxKiPerDiscipline", () => { + it("caps at 2/3/4/5/6 at monk level 3/5/9/13/17 (PHB'14 p.80)", () => { + expect(maxKiPerDiscipline(3)).toBe(2); + expect(maxKiPerDiscipline(4)).toBe(2); + expect(maxKiPerDiscipline(5)).toBe(3); + expect(maxKiPerDiscipline(8)).toBe(3); + expect(maxKiPerDiscipline(9)).toBe(4); + expect(maxKiPerDiscipline(12)).toBe(4); + expect(maxKiPerDiscipline(13)).toBe(5); + expect(maxKiPerDiscipline(16)).toBe(5); + expect(maxKiPerDiscipline(17)).toBe(6); + expect(maxKiPerDiscipline(20)).toBe(6); + }); +}); + +describe("disciplineEffectSpec", () => { + it("scales a damage discipline by poolStep (ki spent above base cost)", () => { + const spec = disciplineEffectSpec({ + name: "Fangs of the Fire Snake", + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 1, + effectDiceFaces: 10, + damageType: "fire", + attackType: "attack", + }); + expect(spec.effectType).toBe("damage"); + expect(spec.dice).toEqual({ count: 1, faces: 10, modifier: 0 }); + expect(spec.scaling).toEqual({ mode: "poolStep", dicePerStep: 1 }); + expect(spec.concentration).toBe(false); + }); + + it("marks the 7 concentration disciplines (PHB'14 p.81 spell equivalents) and no others", () => { + const CONCENTRATES = [ + "Rush of the Gale Spirits", + "Clench of the North Wind", + "Mist Stance", + "Ride the Wind", + "Eternal Mountain Defense", + "River of Hungry Flame", + "Wave of Rolling Earth", + ]; + const NOT = [ + "Fangs of the Fire Snake", + "Fist of Four Thunders", + "Fist of Unbroken Air", + "Shape the Flowing River", + "Sweeping Cinder Strike", + "Water Whip", + "Gong of the Summit", + "Flames of the Phoenix", + "Breath of Winter", + ]; + for (const name of CONCENTRATES) { + expect(disciplineEffectSpec({ name }).concentration, name).toBe(true); + } + for (const name of NOT) { + expect(disciplineEffectSpec({ name }).concentration, name).toBe(false); + } + }); + + it("has no dice for a utility discipline (no effectKind)", () => { + const spec = disciplineEffectSpec({ name: "Shape the Flowing River" }); + expect(spec.dice).toBeUndefined(); + expect(spec.effectType).toBe("utility"); + }); +}); diff --git a/backend/src/lib/classes/__tests__/feature-edition.test.ts b/backend/src/lib/classes/__tests__/feature-edition.test.ts index a207da02..74167b3b 100644 --- a/backend/src/lib/classes/__tests__/feature-edition.test.ts +++ b/backend/src/lib/classes/__tests__/feature-edition.test.ts @@ -651,36 +651,41 @@ const EXPECTED_EDITION_TAGGED_FEATURES = [ ["paladin", "oath of vengeance", "Relentless Avenger"], ["paladin", "oath of vengeance", "Soul of Vengeance"], ["paladin", "oath of vengeance", "Avenging Angel"], - // Monk's 14 new triples (#1500 base class + #1501 Open Hand's new context + - // #1502 Shadow's new context — Warrior of the Elements/Warrior of Mercy - // stay an untouched transport-only twin, #1503): of the base class's 12 - // forked-or-unique-per-edition features, only "Martial Arts" and - // "Stunning Strike" keep the SAME NAME across both editions (SRD 5.1 and - // PHB'24 both call them that, with genuinely different text) — taggedNamesFor - // only flags a name carrying two DIFFERENT descriptions, so a rename is - // never tagged (mirrors Warlock's Expanded Spell List -> Fiend Spells, - // Cleric's Domain Spells -> Life/Trickery Domain Spells above): - // "Ki"/"Focus", "Deflect Missiles"/"Deflect Attacks", "Ki-Empowered - // Strikes"/"Empowered Strikes", "Diamond Soul"/"Disciplined Survivor" are - // each a single description under their own name, and the eleven wholly - // edition-exclusive names (Uncanny Metabolism/Heightened Focus/Self- - // Restoration/Perfect Focus/Superior Defense on the 2024 side; Stillness - // of Mind/Purity of Body/Tongue of the Sun and Moon/Timeless Body/Empty - // Body/Perfect Self on the 2014 side) have no counterpart to fork against - // at all. Both base names show up under EVERY subclass context Monk has - // (undefined/warrior of the open hand/way of the open hand/warrior of - // shadow/way of shadow/warrior of the elements/warrior of mercy — - // collectTaggedFeatureKeys combines classRows, always ALL of them, with - // each context's own subclassRows) — 2 names x 7 contexts = 14. Neither - // Way of the Open Hand's nor Way of Shadow's OWN feature names are tagged - // here — #1501/#1502 each forked their 2014 sibling into a SEPARATE - // subclass rather than a same-slug fork, so loadDbFeatureRows("monk", "way - // of …") only ever returns EDITION_2014 rows: one description per name, - // not two (same "separate subclass, not a fork" shape as Barbarian's Totem - // Warrior or Warlock's The Archfey above). Warrior of the Open Hand's and - // Warrior of Shadow's own feature names are no longer tagged either (they - // used to be, pre-#1501/#1502, when one slug held both editions' text) — - // both slugs' rows are now EDITION_2024-only too. + // Monk's 16 new triples (#1500 base class + #1501 Open Hand's new context + + // #1502 Shadow's new context + #1503 Way of the Four Elements' new context + // — Warrior of the Elements/Warrior of Mercy stay an untouched + // transport-only twin for THIS ledger's purposes, since neither's own + // feature names fork): of the base class's 12 forked-or-unique-per-edition + // features, only "Martial Arts" and "Stunning Strike" keep the SAME NAME + // across both editions (SRD 5.1 and PHB'24 both call them that, with + // genuinely different text) — taggedNamesFor only flags a name carrying two + // DIFFERENT descriptions, so a rename is never tagged (mirrors Warlock's + // Expanded Spell List -> Fiend Spells, Cleric's Domain Spells -> Life/ + // Trickery Domain Spells above): "Ki"/"Focus", "Deflect Missiles"/"Deflect + // Attacks", "Ki-Empowered Strikes"/"Empowered Strikes", "Diamond + // Soul"/"Disciplined Survivor" are each a single description under their + // own name, and the eleven wholly edition-exclusive names (Uncanny + // Metabolism/Heightened Focus/Self-Restoration/Perfect Focus/Superior + // Defense on the 2024 side; Stillness of Mind/Purity of Body/Tongue of the + // Sun and Moon/Timeless Body/Empty Body/Perfect Self on the 2014 side) have + // no counterpart to fork against at all. Both base names show up under + // EVERY subclass context Monk has (undefined/warrior of the open hand/way + // of the open hand/warrior of shadow/way of shadow/warrior of the + // elements/way of the four elements/warrior of mercy — collectTaggedFeatureKeys + // combines classRows, always ALL of them, with each context's own + // subclassRows) — 2 names x 8 contexts = 16. None of Way of the Open + // Hand's, Way of Shadow's, or Way of the Four Elements' OWN feature names + // are tagged here — #1501/#1502 each forked their 2014 sibling into a + // SEPARATE subclass rather than a same-slug fork, and #1503's Way of the + // Four Elements has no 2024 counterpart to fork against at all, so + // loadDbFeatureRows("monk", "way of …") only ever returns EDITION_2014 + // rows: one description per name, not two (same "separate subclass, not a + // fork" shape as Barbarian's Totem Warrior or Warlock's The Archfey + // above). Warrior of the Open Hand's and Warrior of Shadow's own feature + // names are no longer tagged either (they used to be, pre-#1501/#1502, + // when one slug held both editions' text) — both slugs' rows are now + // EDITION_2024-only too, same as Warrior of the Elements' own rows since + // #1503's retag. ["monk", "undefined", "Martial Arts"], ["monk", "undefined", "Stunning Strike"], ["monk", "warrior of the open hand", "Martial Arts"], @@ -693,6 +698,8 @@ const EXPECTED_EDITION_TAGGED_FEATURES = [ ["monk", "way of shadow", "Stunning Strike"], ["monk", "warrior of the elements", "Martial Arts"], ["monk", "warrior of the elements", "Stunning Strike"], + ["monk", "way of the four elements", "Martial Arts"], + ["monk", "way of the four elements", "Stunning Strike"], ["monk", "warrior of mercy", "Martial Arts"], ["monk", "warrior of mercy", "Stunning Strike"], ] as const; diff --git a/backend/src/lib/classes/__tests__/no-disciplines-known-key.test.ts b/backend/src/lib/classes/__tests__/no-disciplines-known-key.test.ts new file mode 100644 index 00000000..8257bbbf --- /dev/null +++ b/backend/src/lib/classes/__tests__/no-disciplines-known-key.test.ts @@ -0,0 +1,29 @@ +// #1503 AC: `disciplinesKnown` must never come back as a state key — +// resurrecting it would silently re-attach the orphaned pre-retirement +// `resources.disciplinesKnown` array a live dev-database character carries +// (a snapshot of a deleted `disciplineId`, #1247/34f5a4cf). The real key is +// choicesKnown["fourElementsDisciplines"] (#899's generic mechanism). +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("no `disciplinesKnown` key anywhere under backend/src (#1503)", () => { + it("grep -rn disciplinesKnown backend/src returns nothing (outside this test's own why-comment)", () => { + const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); + let output = ""; + try { + output = execFileSync( + "grep", + ["-rn", "--exclude=no-disciplines-known-key.test.ts", "disciplinesKnown", "backend/src"], + { cwd: repoRoot, encoding: "utf-8" }, + ); + } catch (err) { + // grep exits 1 when nothing matches — the PASSING case. + const status = (err as { status?: number }).status; + if (status === 1) return; + throw err; + } + expect(output, `found disciplinesKnown reference(s):\n${output}`).toBe(""); + }); +}); diff --git a/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts b/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts index e2c68f16..9f0a54d0 100644 --- a/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts +++ b/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts @@ -2772,7 +2772,10 @@ export const WARRIOR_OF_MERCY_ROWS: ClassFeatureRow[] = (["EDITION_2014", "EDITI }, ]); -export const WARRIOR_OF_THE_ELEMENTS_ROWS: ClassFeatureRow[] = (["EDITION_2014", "EDITION_2024"] as const).flatMap((edition) => [ +// EDITION_2024 only (#1503's retag — its real 2014 predecessor, Way of the +// Four Elements, is a from-scratch discipline menu, not this subclass under +// a different edition tag) — was `.flatMap` over both editions before. +export const WARRIOR_OF_THE_ELEMENTS_ROWS: ClassFeatureRow[] = (["EDITION_2024"] as const).flatMap((edition) => [ { name: "Manipulate Elements", level: 3, diff --git a/backend/src/lib/classes/ability-registry.ts b/backend/src/lib/classes/ability-registry.ts index 9be9fa14..4203d0c8 100644 --- a/backend/src/lib/classes/ability-registry.ts +++ b/backend/src/lib/classes/ability-registry.ts @@ -3,6 +3,7 @@ import { activateCloakOfShadowsOpSchema, attemptStunningStrikeOpSchema, castChannelDivinityOpSchema, + castDisciplineOpSchema, castManeuverOpSchema, castShadowArtOpSchema, dealHandOfHarmOpSchema, @@ -19,6 +20,7 @@ import { applyChannelDivinityOperations, InvalidChannelDivinityOperationError, } from "./channel-divinity.js"; +import { applyDisciplineOperations, InvalidDisciplineOperationError } from "./disciplines.js"; import { applyHandOfHarmOperations, InvalidHandOfHarmOperationError } from "./hand-of-harm.js"; import { applyHandOfUltimateMercyOperations, @@ -84,6 +86,19 @@ export const ABILITY_REGISTRY: Record = { ], }), + // castDiscipline — Way of the Four Elements (2014-only, #1503): spend 2-6 + // ki (per-cast cap by monk level) to cast a known elemental discipline — + // reshape a spell (Thunderwave/Burning Hands/Fireball/…) as the discipline's + // own effect. The client rolls a discipline's damage (mirrors castSpell); + // the server validates the ki spend and, for a concentrating discipline, + // establishes concentration. The known-discipline picker stays a GET on + // the generic /api/subclass-choices/discipline route (#899/#1412). + "disciplines": defineAbility({ + schema: opBatch(castDisciplineOpSchema), + apply: (characterId, data) => applyDisciplineOperations(characterId, data.operations), + domainErrors: [InvalidDisciplineOperationError, InvalidResourceOperationError, InvalidSpellcastingOperationError], + }), + // Once per turn, spends 1 Focus (or a Flurry of Healing and Harm free use at // L11+, via `freeFromFlurry`) to narrate the client-rolled necrotic bonus on // an Unarmed Strike hit; Physician's Touch (L6+) adds the Poisoned rider. diff --git a/backend/src/lib/classes/actions.ts b/backend/src/lib/classes/actions.ts index c5e39df1..bda8d185 100644 --- a/backend/src/lib/classes/actions.ts +++ b/backend/src/lib/classes/actions.ts @@ -590,6 +590,45 @@ const DERIVED_ACTIONS: DerivedActionRecord[] = [ reminder: "Magic action, 2 focus: 20-ft-radius sphere within 120 ft, chosen damage type. Each creature makes a Dexterity save (focus DC) — 3 Martial Arts dice on a failure, half as much on a success.", }, + // Way of the Four Elements (2014-only, PHB'14 pp.78/80-81, #1503). Both + // rows tagged EDITION_2014 and gated to the 2014-only slug — pre-blessed + // safe to reuse the "elementalAttunement" key against the 2024 row above + // (actionGrantLevel filters by edition BEFORE matching by key, and the two + // rows' grantSubclassSlugs are disjoint slugs regardless — #1503's own + // execution-decision comment names this the FIRST same-key-different- + // edition collision this table has ever carried, #1499's own comment + // anticipated it). Elemental Attunement here is free/uncapped and carries + // no resourceKey — unlike 2024's Focus-fuelled buff toggle, PHB'14's + // version is a flat reminder action with no persisted state at all. + // castDiscipline is a discoverability/gate tile only — no ACTION_EFFECT_FN + // entry, same shape as shadowArts below: the real cast is one + // ABILITY_REGISTRY entry (lib/classes/disciplines.ts, "disciplines"), + // dispatched through POST /api/characters/:id/abilities/disciplines/ + // transactions, not this table's generic action-execute path. + { + key: "elementalAttunement", + name: "Elemental Attunement", + cost: "action", + grantClass: "monk", + grantSubclassSlugs: ["monk-way-of-the-four-elements"], + grantLevel: 3, + edition: "EDITION_2014", + reminder: + "Briefly control elemental forces within 30 ft: create a harmless sensory effect; light or snuff a small flame; chill or warm up to 1 lb of nonliving material for 1 hour; or shape a small amount of nonliving earth, fire, water, or mist for 1 minute. Free — always known, no ki cost.", + }, + { + key: "castDiscipline", + name: "Elemental Discipline", + cost: "action", + grantClass: "monk", + grantSubclassSlugs: ["monk-way-of-the-four-elements"], + grantLevel: 3, + edition: "EDITION_2014", + resourceKey: "ki", + resourceAmount: 1, + reminder: "Spend ki to cast a known elemental discipline (2-6 ki, capped by your monk level).", + }, + // Warrior of the Open Hand (#1245): Open Hand Technique (Flurry-hit rider) // and Quivering Palm (set/trigger) are post-hit riders with their own // dedicated verticals (open-hand-technique.ts / quivering-palm.ts), exactly diff --git a/backend/src/lib/classes/disciplines.ts b/backend/src/lib/classes/disciplines.ts new file mode 100644 index 00000000..09bbabb0 --- /dev/null +++ b/backend/src/lib/classes/disciplines.ts @@ -0,0 +1,252 @@ +/** + * Way of the Four Elements discipline cast handler (2014-only, PHB'14 pp. + * 78, 80-81 — not in SRD 5.1, #1503) — the Ki-fuelled analog to + * shadow-arts.ts's Focus-fuelled Darkness cast, mirroring its shared + * focus-cast scaffolding (focus-cast.ts). A discipline is a + * ki-fuelled activated ability catalogued in GrantedAbility (source + * "discipline"); casting one spends ki via the shared payAbilityCostInTx + * pool path and rolls its EffectSpec. + * + * The 5e rules that live here: the per-cast ki cap (PHB'14 p.80's Elemental + * Disciplines table), which disciplines require concentration (the spell + * each one casts), and the ki-scaled EffectSpec build (scaling.mode + * "poolStep", effects.ts). + * + * Unlike maneuvers.ts (a single server-rolled die), a discipline's damage + * roll follows castSpell/castElementalBurst's convention: the effect is the + * monk's own supernatural power, so the CLIENT rolls it and the server only + * validates positivity (ability-cast.ts's own comment, #406) — a discipline + * can deal many scaled dice (up to 8d6 for Flames of the Phoenix), unlike a + * maneuver's fixed single die. + */ + +import { Prisma } from "@/generated/prisma/client.js"; +import type { CastDisciplineOperation } from "@character-sheet/contracts"; + +import { castAbilityInTx } from "@/lib/spellcasting/ability-cast.js"; +import { readAbilityCost, type PayCostContext } from "@/lib/spellcasting/ability-cost.js"; +import { runCharacterTransaction, type CharacterTxContext } from "@/lib/character/character-transaction.js"; +import { levelForExperience } from "@/lib/leveling/experience.js"; +import { effectiveEntryLevel } from "@/lib/leveling/effective-levels.js"; +import { editionOf } from "@/lib/rules/edition.js"; +import { deriveEntryScopedActions } from "./actions.js"; +import { catalogEffectSpec, type EffectSpec } from "@/lib/combat/effects.js"; +import { normalizeResourcesMutable, type ChoiceEntry } from "./resources.js"; +import { normalizeSpellcastingMutable, snapshotSpellcasting } from "@/lib/spellcasting/spell-state.js"; +import { FOCUS_CAST_CHARACTER_SELECT, emitFocusCastEvents } from "./focus-cast.js"; + +export class InvalidDisciplineOperationError extends Error {} + +/** Max ki spendable on ONE discipline cast, by monk level (PHB'14 p.80's Elemental Disciplines table). */ +export function maxKiPerDiscipline(monkLevel: number): number { + return Math.min(6, 2 + Math.floor((monkLevel - 1) / 4)); +} + +// The 7 disciplines that cast a concentration spell (PHB'14 p.81); the other +// 9 cast an instantaneous effect or (Fangs of the Fire Snake) carry no spell +// at all — see disciplines.ts (seed) for the per-discipline citation. +const CONCENTRATION_DISCIPLINES = new Set([ + "Rush of the Gale Spirits", // gust of wind + "Clench of the North Wind", // hold person + "Mist Stance", // gaseous form + "Ride the Wind", // fly + "Eternal Mountain Defense", // stoneskin + "River of Hungry Flame", // wall of fire + "Wave of Rolling Earth", // wall of stone +]); + +// Catalog columns needed to build a discipline's ki-scaled EffectSpec. +export interface DisciplineEffectRow { + name: string; + costPerStep?: number | null; + effectKind?: string | null; + effectDiceCount?: number | null; + effectDiceFaces?: number | null; + effectModifier?: number | null; + damageType?: string | null; + attackType?: string | null; + saveAbility?: string | null; + saveEffect?: string | null; +} + +/** + * Build a discipline's EffectSpec via the shared catalogEffectSpec builder: + * disciplines scale by ki spent above the base cost, so scaling is always + * mode "poolStep" with dicePerStep = costPerStep (0 for a discipline whose + * text allows no overspend), and concentration is the name-set check above. + */ +export function disciplineEffectSpec(row: DisciplineEffectRow): EffectSpec { + return catalogEffectSpec(row, { + scaling: { mode: "poolStep", dicePerStep: row.costPerStep ?? 0 }, + concentrates: (name) => CONCENTRATION_DISCIPLINES.has(name), + }); +} + +type DisciplineRow = Prisma.CharacterGetPayload<{ select: typeof FOCUS_CAST_CHARACTER_SELECT }>; + +function fourElementsMonkEntry(row: DisciplineRow) { + return row.classEntries.find((c) => c.name.toLowerCase() === "monk"); +} + +/** + * Throws unless "castDiscipline" is granted (DERIVED_ACTIONS, actions.ts — + * the same gate the wire's availableActions[] uses, #1315); returns the Four + * Elements monk entry's OWN effective level (not necessarily the total + * character level — a secondary monk's per-cast ki cap scales to its own + * level, mirrors deriveEntryScopedActions' own entry-scoping). + */ +function assertFourElementsMonk(row: DisciplineRow): number { + const monk = fourElementsMonkEntry(row); + const totalLevel = levelForExperience(row.experiencePoints); + const edition = editionOf(row); + const granted = deriveEntryScopedActions(row.classEntries, totalLevel, [], true, edition).some( + (a) => a.key === "castDiscipline", + ); + if (!monk || !granted) { + throw new InvalidDisciplineOperationError("Only a Way of the Four Elements monk (level 3+) can cast elemental disciplines"); + } + return effectiveEntryLevel(monk.level, row.classEntries.length, totalLevel); +} + +// Resolve + validate a known discipline entry (choicesKnown["fourElementsDisciplines"]) +// against the op's entryId, and load its catalog row. `entry.optionId` is an +// ALREADY-PERSISTED id (admitted at learn time by resolveChoiceOption's own +// crossEditionRejection guard, resources.ts) — deliberately unguarded here, +// the exact loadManeuver exemption shape (maneuvers.ts's own comment; see +// scripts/check-catalog-id-edition-guard.sh's ALLOWLIST entry for this +// function). A custom (homebrew) discipline entry has no catalog row and no +// defined cost/effect, so it is rejected rather than guessed at. +async function loadKnownDiscipline( + tx: Prisma.TransactionClient, + row: DisciplineRow, + entryId: string, +): Promise<{ entry: ChoiceEntry; catalog: NonNullable>> }> { + const resources = normalizeResourcesMutable(row.resources); + const known = resources.choicesKnown.fourElementsDisciplines ?? []; + const entry = known.find((e) => e.id === entryId); + if (!entry) { + throw new InvalidDisciplineOperationError(`Discipline not known: ${entryId}`); + } + const catalog = entry.optionId ? await tx.grantedAbility.findUnique({ where: { id: entry.optionId } }) : null; + if (!catalog) { + throw new InvalidDisciplineOperationError(`Discipline not found in catalog: ${entry.name}`); + } + return { entry, catalog }; +} + +/** Validate the ki spent on a discipline: within [base, per-cast cap] for a pool-cost row, 0 for a costless row. */ +function assertDisciplineKiSpend( + disciplineName: string, + cost: ReturnType, + kiSpent: number, + monkLevel: number, +): void { + if (cost.kind !== "pool") { + if (kiSpent !== 0) throw new InvalidDisciplineOperationError(`${disciplineName} costs no ki`); + return; + } + const maxKi = maxKiPerDiscipline(monkLevel); + if (kiSpent < cost.base || kiSpent > maxKi) { + throw new InvalidDisciplineOperationError( + `${disciplineName} costs ${cost.base}-${maxKi} ki at monk level ${monkLevel} (got ${kiSpent})`, + ); + } +} + +// Resolve and validate a single discipline cast against the character row: +// the Four Elements gate, the known-entry + catalog lookup, the discipline's +// own level prerequisite, the ki-spend bounds, and (for a damage discipline) +// a positive client roll. Throws InvalidDisciplineOperationError on any +// failure; returns the pieces castDiscipline needs on success. Split out of +// castDiscipline so the 5e validation rules read as one unit and the apply +// function stays a thin resolve/cast/log body (keeps both under the +// CRAP/cyclomatic bar — mirrors the pre-retirement engine's own +// resolveDisciplineCast split, 34f5a4cf^:lib/classes/disciplines.ts). +async function resolveDisciplineCast( + tx: Prisma.TransactionClient, + row: DisciplineRow, + op: CastDisciplineOperation, +) { + const monkLevel = assertFourElementsMonk(row); + const { entry, catalog } = await loadKnownDiscipline(tx, row, op.entryId); + if (catalog.minLevel > monkLevel) { + throw new InvalidDisciplineOperationError(`${catalog.name} requires monk level ${catalog.minLevel}+ (you are level ${monkLevel})`); + } + + const cost = readAbilityCost(catalog); + const kiSpent = cost.kind === "pool" ? (op.requestedKi ?? cost.base) : 0; + assertDisciplineKiSpend(catalog.name, cost, kiSpent, monkLevel); + + const effect = disciplineEffectSpec(catalog); + const concentrates = effect.concentration ?? false; + const roll = effect.dice ? (op.roll ?? 0) : 0; + if (effect.dice && roll <= 0) { + throw new InvalidDisciplineOperationError(`${catalog.name} requires a positive damage roll`); + } + + return { entry, catalog, cost, kiSpent, effect, concentrates, roll }; +} + +async function castDiscipline( + ctx: CharacterTxContext, +): Promise { + const { tx, row, op, characterId, batchId, sessionId } = ctx; + const { entry, catalog, cost, kiSpent, effect, concentrates, roll } = await resolveDisciplineCast(tx, row, op); + + // fallow-ignore-next-line code-duplication -- the same spellState/beforeSpell/costCtx setup + castAbilityInTx call shape as shadow-arts.ts's applyCastShadowArt (the ".fallowrc.jsonc" clone group this file's own header predicted, #642's original pairing). Not consolidated further here: #1503 is explicitly barred from touching shadow-arts.ts (owned by the parallel #1501/#1502 slices in this epic) — extracting a shared helper would require editing that file too. + const spellState = normalizeSpellcastingMutable(row.spellcasting); + const beforeSpell = snapshotSpellcasting(spellState); + + const costCtx: PayCostContext = { tx, characterId, batchId, sessionId }; + const outcome = await castAbilityInTx( + { tx, characterId, batchId, sessionId, cost: costCtx, concentrationHost: spellState }, + { + name: catalog.name, + entryId: entry.id, + cost, + effect, + requested: cost.kind === "pool" ? kiSpent : undefined, + roll, + eventType: "castDiscipline", + concentrates, + }, + ); + + // Shared focus-cast audit tail (focus-cast.ts): when concentrating, persist + // the write-back + log the undoable spellcasting event (restores + // concentratingOn on revert). The resources cast record restores nothing + // (ki refunded by the pool payer's own spendResource event, concentration + // by the event above), so it carries only the roll/ki data. + await emitFocusCastEvents(tx, { + characterId, + batchId, + sessionId, + eventType: "castDiscipline", + concentrates, + spellState, + beforeSpell, + concentrationName: catalog.name, + concentrationData: { entryId: entry.id, disciplineId: catalog.id, disciplineName: catalog.name }, + resourceSummary: outcome.summary, + resourceData: { entryId: entry.id, disciplineId: catalog.id, kiSpent, roll }, + }); +} + +/** + * Applies a batch of discipline cast operations atomically. Mirrors + * applyShadowArtsOperations: one batchId, LIFO-undoable events, state + * re-read per op. Each cast: the pool payer logs its own spendResource event + * (refunds ki on revert); a concentrating discipline logs a spellcasting- + * category event (restores concentratingOn on revert); the resources- + * category castDiscipline event records the cast. + */ +export async function applyDisciplineOperations( + characterId: string, + operations: CastDisciplineOperation[], +): Promise { + await runCharacterTransaction(characterId, operations, { + select: FOCUS_CAST_CHARACTER_SELECT, + notFound: (id) => new InvalidDisciplineOperationError(`Character not found: ${id}`), + applyOp: castDiscipline, + }); +} diff --git a/backend/src/lib/classes/focus-cast.ts b/backend/src/lib/classes/focus-cast.ts index 2f9aac93..04473c92 100644 --- a/backend/src/lib/classes/focus-cast.ts +++ b/backend/src/lib/classes/focus-cast.ts @@ -1,10 +1,10 @@ /** - * Shared scaffolding for the Monk focus-cast handlers (currently: shadow-arts). - * Wraps castAbilityInTx with a shared character-select and audit-event tail; the - * per-subclass 5e rules (effect specs, level gates, focus costs) stay in their - * own files. Full unification of those divergent parts is the job of the - * declarative subclass engine (#416) — this module only removes the - * byte-for-byte clone (fallow dup:a64b5a27). + * Shared scaffolding for the Monk focus/ki-cast handlers (shadow-arts, + * disciplines). Wraps castAbilityInTx with a shared character-select and + * audit-event tail; the per-subclass 5e rules (effect specs, level gates, + * pool costs) stay in their own files. Full unification of those divergent + * parts is the job of the declarative subclass engine (#416) — this module + * only removes the byte-for-byte clone (fallow dup:a64b5a27). */ import { Prisma } from "@/generated/prisma/client.js"; @@ -34,7 +34,7 @@ export const FOCUS_CAST_CHARACTER_SELECT = { } satisfies Prisma.CharacterSelect; /** The audit-event `type`s emitted by the shared focus-cast tail. */ -type FocusCastEventType = Extract; +type FocusCastEventType = Extract; export interface EmitFocusCastEventsParams { characterId: string; diff --git a/backend/src/lib/classes/monk.ts b/backend/src/lib/classes/monk.ts index 134cc652..d02095d8 100644 --- a/backend/src/lib/classes/monk.ts +++ b/backend/src/lib/classes/monk.ts @@ -31,11 +31,13 @@ export function monkPoolKey(edition: RulesEdition): "ki" | "focus" { // Feature TEXT moved off this module onto literal seed rows // (prisma/seed/monk-features.ts, #1675) — the twelfth and last class retab // (#1134/#1522's roster completion). This module survives (unlike fighter.ts/ -// barbarian.ts/rogue.ts, deleted outright) purely for its resourceFn's: the -// base Focus/Ki pool below, and three subclasses' own pools (Wholeness of -// Body, Flurry of Healing and Harm, Hand of Ultimate Mercy) — none of which -// #1675 moved (transport-only slice, pools are a later chunk's job per -// #1313's plan). +// barbarian.ts/rogue.ts, deleted outright) for its resourceFn's — the base +// Focus/Ki pool below, and three subclasses' own pools (Wholeness of Body, +// Flurry of Healing and Harm, Hand of Ultimate Mercy) — and, since #1503, for +// Way of the Four Elements' `choices` declaration: the ONE piece of a +// SubclassDefinition #1675 never migrated to monk-features.ts, because +// choices (#899) is a mechanism, not feature text (its option catalog is +// disciplines.ts, its own seed module). export const monk: ClassDefinition = { // subclassKey is unused here — the base monk pool never needs to resolve a // subclass-specific variant (unlike druid's wildShape, #906) — but the full @@ -195,5 +197,31 @@ export const monk: ClassDefinition = { return pools; }, }, + "way of the four elements": { + slug: "monk-way-of-the-four-elements", + grantLevel: 3, + // Disciple of the Elements (L3, PHB'14 p.78/80) / Elemental Attunement + // (L3, always-known, a free reminder action) / castDiscipline (L3) gate + // as DERIVED_ACTIONS rows (actions.ts), same as every other monk + // subclass action (#1315) — no deriveExtras booleans here. Feature TEXT + // lives in monk-features.ts (#1675's pattern); this is the ONE + // mechanical piece feature text can't express — the generic "choose N + // from a catalog" declaration (#899). Elemental Attunement is + // deliberately absent from `choices`: PHB'14 grants it free and + // uncapped, so it's a feature row, not a catalog pick. + // + // count is the discipline SLOT cap, not the total known (which also + // includes the always-known Elemental Attunement): 1/2/3/4 at + // L3/6/11/17, so total known reads 2/3/4/5 — do not "fix" this to + // 2/3/4/5 (#1503's explicit decision). + choices: [ + { + key: "fourElementsDisciplines", + label: "Elemental Disciplines", + catalogSource: "discipline", + count: (level) => (level >= 17 ? 4 : level >= 11 ? 3 : level >= 6 ? 2 : level >= 3 ? 1 : 0), + }, + ], + }, }, }; diff --git a/backend/src/lib/classes/resources.ts b/backend/src/lib/classes/resources.ts index a4474e80..3f00aafd 100644 --- a/backend/src/lib/classes/resources.ts +++ b/backend/src/lib/classes/resources.ts @@ -63,6 +63,7 @@ import type { // submission/transaction, ability-cost, the actions + resources routes) keep // resolving them unchanged. export type { + ForgetSubclassChoiceOperation, LearnManeuverOperation, LearnSubclassChoiceOperation, LearnToolProficiencyOperation, @@ -106,6 +107,7 @@ function applySpendResourceOp( ): ResourceOpAudit { const amount = op.amount ?? 1; if (amount <= 0) { + // fallow-ignore-next-line code-duplication -- applySpendResourceOp/applyRestoreResourceOp share a parallel validate-amount/find-pool/bounds-check shape (spend bounds against pool.total, restore bounds against 0) — pre-existing (unrelated to #1503's own diff), not a target for consolidation here. throw new InvalidResourceOperationError("spendResource: amount must be positive"); } const pool = derivedInfo?.resources.find((r) => r.key === op.key); diff --git a/backend/src/lib/classes/subclass-slug.ts b/backend/src/lib/classes/subclass-slug.ts index 819c1120..7ad1fac3 100644 --- a/backend/src/lib/classes/subclass-slug.ts +++ b/backend/src/lib/classes/subclass-slug.ts @@ -47,6 +47,7 @@ export const SUBCLASS_SLUGS = [ "monk-warrior-of-the-elements", "monk-warrior-of-the-open-hand", "monk-way-of-shadow", + "monk-way-of-the-four-elements", "monk-way-of-the-open-hand", "paladin-oath-of-devotion", "paladin-oath-of-the-ancients", @@ -65,7 +66,7 @@ export const SUBCLASS_SLUGS = [ "wizard-school-of-abjuration", "wizard-school-of-evocation", "wizard-school-of-illusion", -] as const; // 34 members — the seed's row count (#1277 F1) and the +] as const; // 35 members — the seed's row count (#1277 F1) and the // lib/classes/*.ts subclass-definition count (#1277 F2) are already a // perfect bijection; this list is exhaustive over both. Bladesinging // (#1676, TCoE p.76) is identity-only like Fighter's subclasses — no @@ -79,6 +80,11 @@ export const SUBCLASS_SLUGS = [ // whose 2014 and 2024 counterparts are genuinely SEPARATE subclasses (Way of // the Open Hand / Warrior of the Open Hand) rather than one name shared // across editions — see monk.ts's own two SubclassDefinition entries. +// monk-way-of-the-four-elements (#1503, PHB'14 pp.80-81) is the third 2014 +// monk fork and the first with no 2024 counterpart at all (Warrior of the +// Elements is a from-scratch PHB'24 rebuild, not this subclass under a +// different edition tag) — it DOES register a SubclassDefinition (monk.ts), +// unlike Bladesinging. export type SubclassSlug = (typeof SUBCLASS_SLUGS)[number]; @@ -111,6 +117,7 @@ export const SUBCLASS_IDENTITY: Record = { "monk-warrior-of-the-elements": { classKey: "monk", nameKey: "warrior of the elements" }, "monk-warrior-of-the-open-hand": { classKey: "monk", nameKey: "warrior of the open hand" }, "monk-way-of-shadow": { classKey: "monk", nameKey: "way of shadow" }, + "monk-way-of-the-four-elements": { classKey: "monk", nameKey: "way of the four elements" }, "monk-way-of-the-open-hand": { classKey: "monk", nameKey: "way of the open hand" }, "paladin-oath-of-devotion": { classKey: "paladin", nameKey: "oath of devotion" }, "paladin-oath-of-the-ancients": { classKey: "paladin", nameKey: "oath of the ancients" }, diff --git a/backend/src/lib/classes/types.ts b/backend/src/lib/classes/types.ts index 5ca3ce01..fcbc4f85 100644 --- a/backend/src/lib/classes/types.ts +++ b/backend/src/lib/classes/types.ts @@ -252,6 +252,33 @@ export interface DerivedSubclassChoice { count: number; } +/** + * A choose-N subclass choice's swap-on-learn cadence (#1503, owner decision + * 2026-08-03) — the choose-N analog of spellcasting-tables.ts's + * `swapCadenceFor`, deliberately a SEPARATE function: `swapCadenceFor` keys + * on (class, subclass) and feeds `preparedSpellCountAt`/`maxSpellLevelForClass`, + * both spell-shaped; adding a non-caster subclass there would make it read as + * a caster to those. Lives here (not resources.ts) so leveling/level-up-plan.ts + * — a "no DB, no Prisma" pure planner — can call it without importing + * resources.ts's Prisma-typed transaction machinery. + * + * Every choose-N choice defaults to "never" (the original, still-correct + * policy for every OTHER choose-N feature, e.g. Ranger's Hunter's Prey + * tiers, Barbarian totems) — "onLevelUp" is the exception, reserved for a + * choice whose OWN 5e text states "whenever you learn a new X, you may + * replace one you know" (PHB'14 p.80, Way of the Four Elements' Disciple of + * the Elements). `edition` last (subclassGateLevel's pattern, #1499) even + * though only one source/edition pair currently answers "onLevelUp" — future + * choose-N features (#1516) extend this function, never duplicate it. Gates + * only the LEVEL-UP CEREMONY's own swap path (LevelUpSubmission.subclassChoicesForgotten); + * the generic forgetSubclassChoice op on POST .../resources/transactions + * stays unrestricted, same as every other choose-N feature's forget — #1516 + * is the tracked follow-up for a global choose-N forget policy. + */ +export function subclassChoiceSwapCadence(catalogSource: string, edition: RulesEdition): "onLevelUp" | "never" { + return catalogSource === "discipline" && edition === "EDITION_2014" ? "onLevelUp" : "never"; +} + // `subclassKey`/`edition` are both required (never optional) so `edition` can // sit last (the subclassGateLevel pattern, #1499) — a defaulted-then-skipped // middle parameter can't coexist with a later required one. Every existing diff --git a/backend/src/lib/combat/__tests__/effects.test.ts b/backend/src/lib/combat/__tests__/effects.test.ts index a4b13d90..639c0c5c 100644 --- a/backend/src/lib/combat/__tests__/effects.test.ts +++ b/backend/src/lib/combat/__tests__/effects.test.ts @@ -99,6 +99,26 @@ describe("resolveEffectSpec — golden byte-parity", () => { it("utility spell resolves to null", () => { expect(resolveEffectSpec(readEffectSpec(detectMagic), 0, { characterLevel: 1 })).toBeNull(); }); + + // #1503: poolStep is the pool-cost analog of slotUpcast (extra ki/focus spent + // above a discipline's base cost adds costPerStep dice, same formula as an + // upcast spell slot) — the generalised successor to the pre-#1373 disciplines + // engine's discipline-only "focus" scaling mode. + it("poolStep scales by the pool overspend step, identically to slotUpcast", () => { + const spec = catalogEffectSpec( + { + name: "Fangs of the Fire Snake", + effectKind: "damage", + effectDiceCount: 1, + effectDiceFaces: 10, + damageType: "fire", + attackType: "attack", + }, + { scaling: { mode: "poolStep", dicePerStep: 1 }, concentrates: () => false }, + ); + expect(resolveEffectSpec(spec, 0, { characterLevel: 3 })).toEqual({ count: 1, faces: 10, modifier: 0 }); + expect(resolveEffectSpec(spec, 3, { characterLevel: 3 })).toEqual({ count: 4, faces: 10, modifier: 0 }); + }); }); // #817 pins: the shared catalog-row→EffectSpec builder. Today only diff --git a/backend/src/lib/combat/effects.ts b/backend/src/lib/combat/effects.ts index ed17d9e1..062a0659 100644 --- a/backend/src/lib/combat/effects.ts +++ b/backend/src/lib/combat/effects.ts @@ -161,7 +161,10 @@ export function resolveEffectSpec( if (ctx.characterLevel >= 17) count *= 4; else if (ctx.characterLevel >= 11) count *= 3; else if (ctx.characterLevel >= 5) count *= 2; - } else if (spec.scaling.mode === "slotUpcast") { + } else if (spec.scaling.mode === "slotUpcast" || spec.scaling.mode === "poolStep") { + // Same formula for both: a spell-slot upcast step and a pool (ki/focus) + // overspend step (#1503) each add `dicePerStep` dice per step above the + // effect's base cost. count += effectiveStep * (spec.scaling.dicePerStep ?? 0); } diff --git a/backend/src/lib/leveling/__tests__/discipline-reconciliation.test.ts b/backend/src/lib/leveling/__tests__/discipline-reconciliation.test.ts new file mode 100644 index 00000000..c39f42ff --- /dev/null +++ b/backend/src/lib/leveling/__tests__/discipline-reconciliation.test.ts @@ -0,0 +1,153 @@ +/** + * Level-down reconciliation + registry-shape proof for Way of the Four + * Elements' fourElementsDisciplines choice (#1503). The mechanism is + * entirely generic (reconcileSubclassChoices + clampChoicesToCaps, #899) — + * this file proves that generic mechanism actually reaches disciplines, and + * that LEVEL_GATED_RECONCILERS needed no new entry to do it (CLAUDE.md's + * "one shared function" reconciler/clamp pairing invariant). + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; + +import { app } from "@/test-support/app-server.js"; +import { Prisma } from "@/generated/prisma/client.js"; +import { prisma } from "@/lib/core/prisma.js"; +import { ensureTestOwner } from "@/test-support/owner.js"; +import { authCookie } from "@/test-support/auth.js"; + +const OWNER_ID = "owner-discipline-recon"; +let COOKIE: string; +const FIXTURE_ID = "test-discipline-recon-monk-1"; + +// XP thresholds: L3=900, L6=14000, L11=85000, L17=225000. +const XP_L6 = 14000; +const XP_L17 = 225000; + +const BASE = { + id: FIXTURE_ID, + name: "Discipline Reconciliation Test Monk", + alignment: "Neutral", + initiativeBonus: 3, + speed: 40, + hitPoints: { current: 80, max: 80, temp: 0 }, + hitDice: { total: 17, die: "d8" }, + abilityScores: { strength: 10, dexterity: 16, constitution: 12, intelligence: 10, wisdom: 16, charisma: 10 }, + savingThrowProficiencies: ["strength", "dexterity"], + skills: ["stealth"], + toolProficiencies: [], + currency: { cp: 0, sp: 0, gp: 0, pp: 0 }, + rulesEdition: "EDITION_2014" as const, +}; + +let classId: string; + +function disc(id: string, name: string) { + return { id, optionId: `catalog-${id}`, name, description: "fixture" }; +} + +async function createL17Monk() { + await prisma.character.create({ + data: { + ...BASE, + experiencePoints: XP_L17, + ownerId: OWNER_ID, + resources: { + used: {}, + maneuversKnown: [], + toolProficienciesKnown: [], + // 4 known disciplines — the L17 cap. + choicesKnown: { fourElementsDisciplines: [disc("d1", "A"), disc("d2", "B"), disc("d3", "C"), disc("d4", "D")] }, + advancements: [], + } as unknown as Prisma.InputJsonValue, + classEntries: { create: [{ name: "monk", subclass: "way of the four elements", classId, position: 0 }] }, + }, + }); +} + +async function postXp(body: object) { + return supertest(app).post(`/api/characters/${FIXTURE_ID}/experience`).set("Cookie", COOKIE).send(body); +} + +beforeAll(async () => { + const cls = await prisma.characterClass.upsert({ + where: { name: "Discipline Recon Test Monk Class" }, + create: { + name: "Discipline Recon Test Monk Class", + hitDie: "d8", + savingThrows: ["strength", "dexterity"], + skillChoiceCount: 2, + skillChoices: ["acrobatics", "stealth"], + isSpellcaster: false, + }, + update: {}, + }); + classId = cls.id; +}); + +describe("Level-down reconciliation trims fourElementsDisciplines (#1503, generic mechanism #899)", () => { + afterEach(async () => { + await prisma.character.deleteMany({ where: { id: FIXTURE_ID } }); + await prisma.characterClass.deleteMany({ where: { name: "Discipline Recon Test Monk Class" } }); + }); + + it("L17→L6 trims 4 known disciplines to 2 (the L6 cap), logs subclassChoicesReconciled, and undo restores all 4", async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); + await createL17Monk(); + + const res = await postXp({ operations: [{ type: "set", value: XP_L6 }] }); + expect(res.status).toBe(200); + const known = res.body.resources.subclassChoices?.find((c: { key: string }) => c.key === "fourElementsDisciplines"); + expect(known?.count).toBe(2); + + const row = await prisma.character.findUnique({ where: { id: FIXTURE_ID }, select: { resources: true } }); + const choicesKnown = (row!.resources as { choicesKnown: Record }).choicesKnown; + expect(choicesKnown.fourElementsDisciplines).toHaveLength(2); + // LIFO order preserved: the FIRST two entries survive the trim. + expect((choicesKnown.fourElementsDisciplines as { id: string }[]).map((e) => e.id)).toEqual(["d1", "d2"]); + + const reconciled = await prisma.characterEvent.findFirst({ + where: { characterId: FIXTURE_ID, type: "subclassChoicesReconciled" }, + }); + expect(reconciled).not.toBeNull(); + + // LIFO undo: revert the XP-set batch restores all 4. + const batchId = reconciled!.batchId; + const undo = await supertest(app).post(`/api/characters/${FIXTURE_ID}/events/${batchId}/revert`).set("Cookie", COOKIE); + expect(undo.status).toBe(200); + const restored = await prisma.character.findUnique({ where: { id: FIXTURE_ID }, select: { resources: true } }); + expect((restored!.resources as { choicesKnown: Record }).choicesKnown.fourElementsDisciplines).toHaveLength(4); + }); +}); + +// Structural proof (source-text based, mirrors architecture-doc.test.ts's own +// idiom): LEVEL_GATED_RECONCILERS is module-local (not exported), so reading +// its literal array off the source file is the only way to pin its shape +// from outside level-reconciliation.ts without widening that module's public +// surface just for a test. +describe("LEVEL_GATED_RECONCILERS gained no entry for disciplines (#1503)", () => { + it("still 8 reconcilers, reconcileSubclassChoices present, no discipline-specific reconciler added", () => { + const path = fileURLToPath(new URL("../level-reconciliation.js", import.meta.url)).replace(/\.js$/, ".ts"); + const text = readFileSync(path, "utf-8"); + const match = text.match(/const LEVEL_GATED_RECONCILERS: Reconciler\[\] = \[([\s\S]*?)\];/); + expect(match).not.toBeNull(); + const entries = match![1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + expect(entries).toEqual([ + "reconcileClassEntryLevels", + "reconcileSubclass", + "reconcileGrantedSpells", + "reconcilePreparedSpells", + "reconcileManeuvers", + "reconcileToolProficiencies", + "reconcileSubclassChoices", + "reconcileAdvancements", + ]); + expect(entries.some((e) => /discipline/i.test(e))).toBe(false); + }); +}); diff --git a/backend/src/lib/leveling/__tests__/level-up-plan.test.ts b/backend/src/lib/leveling/__tests__/level-up-plan.test.ts index 33623ec8..4f8ec72a 100644 --- a/backend/src/lib/leveling/__tests__/level-up-plan.test.ts +++ b/backend/src/lib/leveling/__tests__/level-up-plan.test.ts @@ -224,6 +224,50 @@ describe("buildLevelUpPlan — generic subclassChoice (#899)", () => { }); }); +// #1503: Way of the Four Elements' fourElementsDisciplines choice is the +// first choose-N whose swapCadence resolves "onLevelUp" — every OTHER +// choose-N (Hunter's Prey above) has no canSwap at all (subclassChoiceSwapCadence +// defaults "never"), so canSwap:true here is the meaningful new assertion. +describe("buildLevelUpPlan — Way of the Four Elements disciplines (#1503)", () => { + it("2→3 grants 1 discipline pick, canSwap true (a new discipline is being learned)", () => { + const plan = buildLevelUpPlan( + char("monk", 2, null, "EDITION_2014"), + target("monk", 3, "way of the four elements"), + ); + const choice = plan.find((s) => s.kind === "subclassChoice"); + expect(choice?.count).toBe(1); + expect(choice?.meta).toMatchObject({ key: "fourElementsDisciplines", catalogSource: "discipline", canSwap: true }); + }); + + it("5→6, 10→11, 16→17 each grant exactly 1 more pick (choice cap 1/2/3/4)", () => { + for (const [from, to] of [[5, 6], [10, 11], [16, 17]] as const) { + const plan = buildLevelUpPlan( + char("monk", from, "way of the four elements", "EDITION_2014"), + target("monk", to, "way of the four elements"), + ); + const choice = plan.find((s) => s.kind === "subclassChoice"); + expect(choice?.count, `L${from}->${to}`).toBe(1); + expect(choice?.meta?.canSwap, `L${from}->${to}`).toBe(true); + } + }); + + it("a level with no new discipline grants no subclassChoice step at all (no swap-only step, unlike newSpells)", () => { + const plan = buildLevelUpPlan( + char("monk", 3, "way of the four elements", "EDITION_2014"), + target("monk", 4, "way of the four elements"), + ); + expect(kinds(plan)).not.toContain("subclassChoice"); + }); + + it("2024 Warrior of the Elements has no subclassChoice step at all (no discipline menu in 2024)", () => { + const plan = buildLevelUpPlan( + char("monk", 2, null, "EDITION_2024"), + target("monk", 3, "warrior of the elements"), + ); + expect(kinds(plan)).not.toContain("subclassChoice"); + }); +}); + describe("buildLevelUpPlan — newSpells (2024 prepared model)", () => { it("Wizard 7→8 scribes 2 spells, after advancement, before review — no swap", () => { const plan = buildLevelUpPlan(char("wizard", 7), target("wizard", 8)); diff --git a/backend/src/lib/leveling/__tests__/level-up-submission.test.ts b/backend/src/lib/leveling/__tests__/level-up-submission.test.ts index cf5789b7..c6ecb73e 100644 --- a/backend/src/lib/leveling/__tests__/level-up-submission.test.ts +++ b/backend/src/lib/leveling/__tests__/level-up-submission.test.ts @@ -269,6 +269,94 @@ describe("validateLevelUpSubmission — known-spell swap (#1101)", () => { }); }); +// #1503: the choose-N swap for Way of the Four Elements' fourElementsDisciplines +// choice — the first choice whose swapCadence resolves "onLevelUp" +// (subclassChoiceSwapCadence). Same shape as the known-spell swap suite above, +// scoped per choiceKey instead of globally. +describe("validateLevelUpSubmission — choose-N swap (#1503, Way of the Four Elements)", () => { + function char4e(level: number): LevelUpPlanCharacter { + return { abilityScores: ABILITIES, classEntries: [{ name: "monk", level, subclass: "way of the four elements" }], edition: "EDITION_2014" }; + } + const t4e = (newLevel: number) => target("monk", newLevel, "way of the four elements"); + const learnDisc = (optionId: string): NonNullable[number] => ({ + type: "learnSubclassChoice", + choiceKey: "fourElementsDisciplines", + optionId, + }); + const forgetDisc = (entryId: string): NonNullable[number] => ({ + type: "forgetSubclassChoice", + choiceKey: "fourElementsDisciplines", + entryId, + }); + const base = { target: { kind: "existing", classEntryId: "x" } as const, hp: { method: "average" as const } }; + + it("5→6 accepts a swap: 2 learns + 1 forget nets to the step's count (1)", () => { + const steps = validateLevelUpSubmission(char4e(5), t4e(6), null, { + ...base, + subclassChoices: [learnDisc("opt-1"), learnDisc("opt-2")], + subclassChoicesForgotten: [forgetDisc("entry-1")], + }); + expect(kinds(steps)).toEqual(["hitPoints", "subclassChoice", "review"]); + }); + + it("rejects two forgets for the same key", () => { + expect(() => + validateLevelUpSubmission(char4e(10), t4e(11), null, { + ...base, + subclassChoices: [learnDisc("opt-1"), learnDisc("opt-2"), learnDisc("opt-3")], + subclassChoicesForgotten: [forgetDisc("entry-1"), forgetDisc("entry-2")], + }), + ).toThrow(/at most one/i); + }); + + // #1101's own Wizard test picks a case where the NET matches the step's + // expected count exactly, so assertCounts passes silently and + // assert(SubclassChoice)Forgets is what actually rejects — a forget-only + // submission that also fails the count check would be rejected by + // assertCounts first with a less specific message. Monk 6→7 is neither an + // ASI level (4/8/12/16/19) nor a discipline-growth level (next threshold + // 11), so no subclassChoice step exists for fourElementsDisciplines at all. + it("rejects a forget on a level with no subclassChoice step for that key at all (no new discipline this level)", () => { + expect(() => + validateLevelUpSubmission(char4e(6), t4e(7), null, { + ...base, + subclassChoicesForgotten: [forgetDisc("entry-1")], + }), + ).toThrow(/does not allow swapping/i); + }); + + it("rejects a forget for a choose-N choice whose swapCadence is NOT onLevelUp (Hunter's Prey)", () => { + expect(() => + validateLevelUpSubmission( + { abilityScores: ABILITIES, classEntries: [{ name: "ranger", level: 6, subclass: "hunter" }], edition: "EDITION_2024" }, + target("ranger", 7, "hunter"), + null, + { + ...base, + // 2 learns - 1 forget = net 1, matching the step's expected count + // (1) exactly, so assertCounts passes and the swap-cadence guard is + // what actually rejects this. + subclassChoices: [ + { type: "learnSubclassChoice", choiceKey: "defensiveTactics", optionId: "opt-1" }, + { type: "learnSubclassChoice", choiceKey: "defensiveTactics", optionId: "opt-2" }, + ], + subclassChoicesForgotten: [{ type: "forgetSubclassChoice", choiceKey: "defensiveTactics", entryId: "entry-1" }], + }, + ), + ).toThrow(/does not allow swapping/i); + }); + + it("rejects a net mismatch (1 learn, 1 forget, but step expects 1 net — i.e. 2 learns needed)", () => { + expect(() => + validateLevelUpSubmission(char4e(5), t4e(6), null, { + ...base, + subclassChoices: [learnDisc("opt-1")], + subclassChoicesForgotten: [forgetDisc("entry-1")], + }), + ).toThrow(/fourElementsDisciplines choices/i); + }); +}); + describe("validateLevelUpSubmission — new cantrips (#1131)", () => { const learn = (spellId: string): NonNullable[number] => ({ type: "learnSpell", spellId }); const forget = (entryId: string): NonNullable[number] => ({ type: "forgetSpell", entryId }); diff --git a/backend/src/lib/leveling/level-up-plan.ts b/backend/src/lib/leveling/level-up-plan.ts index 74283691..fcf96014 100644 --- a/backend/src/lib/leveling/level-up-plan.ts +++ b/backend/src/lib/leveling/level-up-plan.ts @@ -6,6 +6,7 @@ import type { RulesEdition } from "@character-sheet/shared-types"; import { deriveResources, type DerivedClassInfo } from "@/lib/classes/class-features.js"; import type { ClassFeatureRow, ClassFeatureRowsCarrier } from "@/lib/classes/class-feature-rows.js"; +import { subclassChoiceSwapCadence } from "@/lib/classes/types.js"; import { fixedAverageForDie, levelUpHpGain } from "@/lib/combat/hitpoints.js"; import { proficiencyBonusForLevel } from "@/lib/leveling/experience.js"; import { abilityModifier, advancementSlotsForLevel, fightingStyleFeatSlots, hitDieFace } from "@/lib/srd/srd.js"; @@ -201,8 +202,14 @@ function choiceCountStep( return delta > 0 ? { kind, count: delta } : null; } -// Generic subclass "choose N from a catalog" (#899): one step per key that grew. -function subclassChoiceSteps({ now, prev }: PlanContext): LevelUpStep[] { +// Generic subclass "choose N from a catalog" (#899): one step per key that +// grew. `canSwap` (#1503) rides `meta` for a catalogSource whose swap cadence +// is "onLevelUp" — unlike newSpellsStep's swap (legal even on a no-new-picks +// level, so it needs its own swap-only step), a choose-N swap is PHB'14-legal +// only "whenever you learn a new X" — exactly the condition that already +// gates this step's existence (delta > 0) — so no separate swap-only step is +// needed; canSwap just rides the step that's already there. +function subclassChoiceSteps({ now, prev, edition }: PlanContext): LevelUpStep[] { const prevCounts = new Map((prev?.subclassChoices ?? []).map((c) => [c.key, c.count])); return (now?.subclassChoices ?? []) .map((choice) => ({ choice, delta: choice.count - (prevCounts.get(choice.key) ?? 0) })) @@ -210,7 +217,12 @@ function subclassChoiceSteps({ now, prev }: PlanContext): LevelUpStep[] { .map(({ choice, delta }) => ({ kind: "subclassChoice" as const, count: delta, - meta: { key: choice.key, label: choice.label, catalogSource: choice.catalogSource }, + meta: { + key: choice.key, + label: choice.label, + catalogSource: choice.catalogSource, + ...(subclassChoiceSwapCadence(choice.catalogSource, edition) === "onLevelUp" ? { canSwap: true } : {}), + }, })); } diff --git a/backend/src/lib/leveling/level-up-submission.ts b/backend/src/lib/leveling/level-up-submission.ts index db9b8ba6..24325353 100644 --- a/backend/src/lib/leveling/level-up-submission.ts +++ b/backend/src/lib/leveling/level-up-submission.ts @@ -9,6 +9,7 @@ import type { LevelUpTarget } from "@character-sheet/contracts"; import type { AdvancementOperation, TakeFeatOperation } from "@/lib/leveling/advancement.js"; import type { ClassFeatureRow } from "@/lib/classes/class-feature-rows.js"; import type { + ForgetSubclassChoiceOperation, LearnManeuverOperation, LearnToolProficiencyOperation, LearnSubclassChoiceOperation, @@ -43,6 +44,11 @@ export interface LevelUpSubmission { maneuvers?: LearnManeuverOperation[]; toolProficiencies?: LearnToolProficiencyOperation[]; subclassChoices?: LearnSubclassChoiceOperation[]; + // #1503: a swap for a choose-N choice whose swapCadence is "onLevelUp" + // (today: Way of the Four Elements' disciplines) — one forgotten entry + // offset by one extra learn under the SAME choiceKey, mirroring + // spellsForgotten's own shape/assert (assertSubclassChoiceForgets below). + subclassChoicesForgotten?: ForgetSubclassChoiceOperation[]; spellsLearned?: LearnSpellOperation[]; // #1131: new cantrips picked this level — counted against the newSpells step's // meta.cantrips, separately from leveled picks (a cantrip never offsets a swap). @@ -162,6 +168,15 @@ function netSpellsLearned(submission: LevelUpSubmission): number { return (submission.spellsLearned?.length ?? 0) - (submission.spellsForgotten?.length ?? 0); } +// #1503: same shape as netSpellsLearned, scoped to one choiceKey — a swap +// (forget one, learn a different one) offsets, so the NET learn count for +// that key must equal the step's own count. +function netSubclassChoiceLearned(key: unknown, submission: LevelUpSubmission): number { + const learned = (submission.subclassChoices ?? []).filter((c) => c.choiceKey === key).length; + const forgotten = (submission.subclassChoicesForgotten ?? []).filter((c) => c.choiceKey === key).length; + return learned - forgotten; +} + function stepProvided( step: LevelUpStep, chosenSubclassName: string | null, @@ -172,8 +187,7 @@ function stepProvided( } if (step.kind === "subclassChoice") { const key = step.meta?.key; - const provided = (submission.subclassChoices ?? []).filter((c) => c.choiceKey === key).length; - return { provided, noun: `${String(key)} choices` }; + return { provided: netSubclassChoiceLearned(key, submission), noun: `${String(key)} choices` }; } // #1101: a swap offsets its extra learn — the NET learn count must equal the // step count (spellsLearned.length === step.count + spellsForgotten.length). @@ -248,6 +262,33 @@ function assertForgets(plan: LevelUpStep[], character: LevelUpPlanCharacter, sub } } +// #1503: a choose-N swap forgets at most one entry PER choiceKey, only on a +// subclassChoice step whose meta.canSwap is true (subclassChoiceSwapCadence +// resolved "onLevelUp" for that catalogSource/edition) — sibling of +// assertForgets above, same shape, scoped per-key rather than globally since +// each choiceKey is an independent slot. Entry-existence (does the forgotten +// entryId actually belong to that choicesKnown[key] list) is NOT re-checked +// here — applyForgetSubclassChoiceOp (resources.ts) already rejects an +// unknown entryId at apply time; duplicating that check here would be the +// "second bespoke guard" #1503's own decision says not to add. +function assertSubclassChoiceForgets(plan: LevelUpStep[], submission: LevelUpSubmission): void { + const forgets = submission.subclassChoicesForgotten ?? []; + if (forgets.length === 0) return; + const byKey = new Map(); + for (const op of forgets) { + byKey.set(op.choiceKey, (byKey.get(op.choiceKey) ?? 0) + 1); + } + for (const [key, count] of byKey) { + if (count > 1) { + throw new InvalidLevelUpError(`You may swap at most one ${key} choice per level-up.`); + } + const step = plan.find((s) => s.kind === "subclassChoice" && s.meta?.key === key); + if (step?.meta?.canSwap !== true) { + throw new InvalidLevelUpError(`this level-up does not allow swapping a "${key}" choice`); + } + } +} + // #1131: new cantrips ride the newSpells step's meta.cantrips, counted separately // from leveled picks (a cantrip never offsets a swap forget). A level with no // newSpells step — or one granting no cantrips — rejects any cantripsLearned. @@ -282,6 +323,7 @@ export function validateLevelUpSubmission( assertCounts(plan, chosenSubclassName, submission); assertNoExcess(plan, submission); assertForgets(plan, character, submission); + assertSubclassChoiceForgets(plan, submission); assertCantrips(plan, submission); return plan; } diff --git a/backend/src/lib/leveling/level-up-transaction.ts b/backend/src/lib/leveling/level-up-transaction.ts index 4eac4959..06ab57eb 100644 --- a/backend/src/lib/leveling/level-up-transaction.ts +++ b/backend/src/lib/leveling/level-up-transaction.ts @@ -301,10 +301,15 @@ const STEP_OP_BUILDERS: Record [{ domain: "advancement", op: { ...s.fightingStyleFeat!, slot: "fightingStyle" } }], maneuvers: (s) => (s.maneuvers ?? []).map((op) => ({ domain: "resources", op })), toolProficiency: (s) => (s.toolProficiencies ?? []).map((op) => ({ domain: "resources", op })), - subclassChoice: (s, step) => - (s.subclassChoices ?? []) - .filter((c) => c.choiceKey === step.meta?.key) - .map((op) => ({ domain: "resources", op })), + // #1503: forgets apply BEFORE learns (ops run sequentially in tx order), + // mirroring #1101's newSpells ordering — resolveChoiceOption's dup guard + // reads the CURRENT known list, so a forget-first ordering lets a swap + // proceed cleanly even in the (RAW-disallowed but not worth special-casing) + // edge of re-picking the same option. + subclassChoice: (s, step) => [ + ...(s.subclassChoicesForgotten ?? []).filter((c) => c.choiceKey === step.meta?.key), + ...(s.subclassChoices ?? []).filter((c) => c.choiceKey === step.meta?.key), + ].map((op) => ({ domain: "resources", op })), // #1101: forgets apply BEFORE learns (ops run sequentially in tx order), so a // swap can re-learn the just-forgotten spellId without tripping the dup guard. // #1131: cantrips are ordinary learns applied first (disjoint from the swap). diff --git a/backend/src/routes/character/__tests__/disciplines-cast.test.ts b/backend/src/routes/character/__tests__/disciplines-cast.test.ts new file mode 100644 index 00000000..140af862 --- /dev/null +++ b/backend/src/routes/character/__tests__/disciplines-cast.test.ts @@ -0,0 +1,277 @@ +/** + * Way of the Four Elements discipline cast endpoint (2014-only, #1503): + * POST /abilities/disciplines/transactions. Real Postgres + supertest. + * Fixture is a Way of the Four Elements monk whose XP sets the level and + * whose `resources.choicesKnown.fourElementsDisciplines` seeds known + * disciplines directly (the learn/forget flow itself is the generic + * learnSubclassChoice/forgetSubclassChoice machinery, already covered by + * resources.ts's own tests — this file covers the CAST). + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import supertest from "supertest"; + +import { app } from "@/test-support/app-server.js"; +import { Prisma } from "@/generated/prisma/client.js"; +import { prisma } from "@/lib/core/prisma.js"; +import { ensureTestOwner } from "@/test-support/owner.js"; +import { readPinnedEvents } from "@/test-support/events.js"; +import { authCookie } from "@/test-support/auth.js"; + +const OWNER_ID = "owner-discipline-cast"; +let COOKIE: string; + +const FIXTURE_ID = "test-discipline-cast-monk-1"; + +// XP thresholds -> monk level: L3=900, L5=6500, L11=85000. +const XP_L2 = 300; +const XP_L3 = 900; +const XP_L5 = 6500; +const XP_L11 = 85000; + +const url = `/api/characters/${FIXTURE_ID}/abilities/disciplines/transactions`; +const activityUrl = `/api/characters/${FIXTURE_ID}/activity?category=resources`; + +const FIXTURE_BASE = { + id: FIXTURE_ID, + name: "Discipline Cast Test Monk", + alignment: "Neutral", + initiativeBonus: 3, + speed: 40, + hitPoints: { current: 40, max: 40, temp: 0 }, + hitDice: { total: 5, die: "d8" }, + abilityScores: { + strength: 10, dexterity: 16, constitution: 12, intelligence: 10, wisdom: 16, charisma: 10, + }, + savingThrowProficiencies: ["strength", "dexterity"], + skills: ["stealth"], + toolProficiencies: [], + currency: { cp: 0, sp: 0, gp: 0, pp: 0 }, + rulesEdition: "EDITION_2014" as const, +}; + +function agent() { + return supertest.agent(app).set("Cookie", COOKIE); +} +async function cast(operations: unknown[]) { + return agent().post(url).send({ operations }); +} + +interface ActivityEvent { + type: string; + summary: string; + data?: Record; + batchId?: string; +} +async function activity(): Promise { + const res = await agent().get(activityUrl); + return res.body as ActivityEvent[]; +} + +let classId: string; +let disciplinesByName: Record; + +// Seeds resources.choicesKnown.fourElementsDisciplines directly — bypasses +// the learn flow (already covered generically by resources.ts tests) so this +// file's own fixtures can pin a known entryId per test. +function knownDiscipline(entryId: string, name: string) { + return { id: entryId, optionId: disciplinesByName[name].id, name, description: "fixture" }; +} + +async function createMonk(experiencePoints: number, known: ReturnType[]) { + const sub = await prisma.subclass.findFirst({ + where: { classId, name: { equals: "Way of the Four Elements", mode: "insensitive" } }, + select: { id: true }, + }); + await prisma.character.create({ + data: { + ...FIXTURE_BASE, + experiencePoints, + ownerId: OWNER_ID, + resources: { + used: {}, + maneuversKnown: [], + toolProficienciesKnown: [], + choicesKnown: { fourElementsDisciplines: known }, + advancements: [], + } as unknown as Prisma.InputJsonValue, + classEntries: { + create: [{ name: "monk", subclass: "way of the four elements", subclassId: sub?.id, classId, position: 0 }], + }, + }, + }); +} + +describe("Discipline cast endpoint (#1503)", () => { + beforeAll(async () => { + const cls = await prisma.characterClass.upsert({ + where: { name: "Discipline Cast Test Monk Class" }, + create: { + name: "Discipline Cast Test Monk Class", + hitDie: "d8", + savingThrows: ["strength", "dexterity"], + skillChoiceCount: 2, + skillChoices: ["acrobatics", "stealth"], + isSpellcaster: false, + }, + update: {}, + }); + classId = cls.id; + + const rows = await prisma.grantedAbility.findMany({ where: { source: "discipline" } }); + if (rows.length !== 16) throw new Error(`discipline catalog not seeded (${rows.length}/16) — run \`prisma db seed\``); + disciplinesByName = Object.fromEntries(rows.map((r) => [r.name, { id: r.id }])); + }); + + afterAll(async () => { + await prisma.characterClass.deleteMany({ where: { name: "Discipline Cast Test Monk Class" } }); + }); + + beforeEach(async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); + }); + + afterEach(async () => { + await prisma.character.deleteMany({ where: { id: FIXTURE_ID } }); + }); + + // Concentration is asserted by reading `spellcasting` straight off the DB + // row, not the serialized wire body: `spellcasting` is omitted from the + // wire entirely for a character with zero granted spells (spellcasting.ts's + // own `granted.length === 0 && …` early-return) — this fixture's throwaway + // test subclass grants none (Way of the Four Elements grants no spells in + // real content either), so the DB is the only reliable read here. + async function dbConcentratingOn(): Promise { + const row = await prisma.character.findUnique({ where: { id: FIXTURE_ID }, select: { spellcasting: true } }); + return (row!.spellcasting as { concentratingOn: unknown } | null)?.concentratingOn ?? null; + } + + it("casts a base-cost, non-concentrating, damage discipline (Fangs of the Fire Snake) — spends 1 ki, no concentration", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1", roll: 7 }]); + expect(res.status).toBe(200); + const ki = res.body.resources.pools.find((p: { key: string }) => p.key === "ki"); + expect(ki.used).toBe(1); + expect(await dbConcentratingOn()).toBeNull(); + + const events = await activity(); + const castEvent = events.find((e) => e.type === "castDiscipline")!; + expect(castEvent.data).toMatchObject({ entryId: "e1", kiSpent: 1, roll: 7 }); + expect(events.some((e) => e.type === "spendResource")).toBe(true); + }); + + it("overspending ki scales the discipline's damage (poolStep) and spends the requested amount", async () => { + await createMonk(XP_L5, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + // Fangs costs 1 base + 1/step; monk L5's per-cast cap is 3 (PHB'14 p.80). + const res = await cast([{ type: "castDiscipline", entryId: "e1", requestedKi: 3, roll: 30 }]); + expect(res.status).toBe(200); + expect(res.body.resources.pools.find((p: { key: string }) => p.key === "ki").used).toBe(3); + }); + + it("casting a concentrating discipline (Rush of the Gale Spirits) establishes concentration", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Rush of the Gale Spirits")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1" }]); + expect(res.status).toBe(200); + expect(res.body.resources.pools.find((p: { key: string }) => p.key === "ki").used).toBe(2); + expect(await dbConcentratingOn()).toMatchObject({ entryId: "e1", spellName: "Rush of the Gale Spirits" }); + }); + + it("rejects a cast spending more ki than the per-cast cap (L3 cap 2, requesting 3)", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1", requestedKi: 3, roll: 10 }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/1-2 ki at monk level 3/); + }); + + it("rejects a cast of a discipline above the character's level (Eternal Mountain Defense, minLevel 13)", async () => { + await createMonk(XP_L11, [knownDiscipline("e1", "Eternal Mountain Defense")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1" }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/requires monk level 13/); + }); + + it("rejects a cast of a discipline not known", async () => { + await createMonk(XP_L3, []); + const res = await cast([{ type: "castDiscipline", entryId: "not-known" }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not known/); + }); + + it("rejects a cast from a sub-L3 Four Elements monk", async () => { + await createMonk(XP_L2, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1", roll: 5 }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/level 3/i); + }); + + it("rejects a cast from a non-Four-Elements monk", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + await prisma.characterClassEntry.updateMany({ where: { characterId: FIXTURE_ID }, data: { subclass: "warrior of the open hand", subclassId: null } }); + const res = await cast([{ type: "castDiscipline", entryId: "e1", roll: 5 }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Way of the Four Elements/i); + }); + + it("rejects a damage discipline cast with no positive roll", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1" }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/positive damage roll/); + }); + + it("a utility discipline (Shape the Flowing River) needs no roll and deals no damage", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Shape the Flowing River")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1" }]); + expect(res.status).toBe(200); + expect(res.body.resources.pools.find((p: { key: string }) => p.key === "ki").used).toBe(1); + }); + + it("logs an undoable cast: revert refunds ki and restores concentration to null", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Rush of the Gale Spirits")]); + const casted = await cast([{ type: "castDiscipline", entryId: "e1" }]); + expect(casted.body.resources.pools.find((p: { key: string }) => p.key === "ki").used).toBe(2); + + const events = await activity(); + const batchId = events.find((e) => e.type === "castDiscipline")!.batchId!; + const undo = await agent().post(`/api/characters/${FIXTURE_ID}/events/${batchId}/revert`); + expect(undo.status).toBe(200); + expect(undo.body.resources.pools.find((p: { key: string }) => p.key === "ki").used).toBe(0); + + const reverted = await prisma.character.findUnique({ where: { id: FIXTURE_ID }, select: { spellcasting: true } }); + expect((reverted!.spellcasting as { concentratingOn: unknown }).concentratingOn).toBeNull(); + }); + + // #1503 AC: castDiscipline's audit trail is exactly a spendResource + + // castDiscipline pair (+ a concentration event for a concentrating cast). + it("pins the audit trail of a non-concentrating cast (spendResource + castDiscipline, no concentration event)", async () => { + await createMonk(XP_L3, [knownDiscipline("e1", "Fangs of the Fire Snake")]); + const res = await cast([{ type: "castDiscipline", entryId: "e1", roll: 7 }]); + expect(res.status).toBe(200); + + const events = await readPinnedEvents(FIXTURE_ID); + expect(events.map((e) => e.category + ":" + e.type)).toEqual(["resources:castDiscipline", "resources:spendResource"]); + }); +}); + +describe("GET /api/subclass-choices/discipline (#1503)", () => { + beforeEach(async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); + }); + + it("returns the 16-row 2014 catalog with cost + alwaysKnown; 2024 gets none", async () => { + const as2014 = await supertest.agent(app).set("Cookie", COOKIE).get("/api/subclass-choices/discipline?edition=EDITION_2014"); + expect(as2014.status).toBe(200); + expect((as2014.body as unknown[]).length).toBe(16); + const fangs = (as2014.body as { name: string; cost: unknown; alwaysKnown: boolean; minLevel: number }[]).find( + (r) => r.name === "Fangs of the Fire Snake", + )!; + expect(fangs.cost).toEqual({ kind: "pool", key: "ki", base: 1, perStep: 1 }); + expect(fangs.alwaysKnown).toBe(false); + expect(fangs.minLevel).toBe(3); + + const as2024 = await supertest.agent(app).set("Cookie", COOKIE).get("/api/subclass-choices/discipline?edition=EDITION_2024"); + expect(as2024.status).toBe(200); + expect((as2024.body as unknown[]).length).toBe(0); + }); +}); diff --git a/backend/src/routes/character/__tests__/disciplines-subclass-choice.test.ts b/backend/src/routes/character/__tests__/disciplines-subclass-choice.test.ts new file mode 100644 index 00000000..b4ce25d7 --- /dev/null +++ b/backend/src/routes/character/__tests__/disciplines-subclass-choice.test.ts @@ -0,0 +1,132 @@ +/** + * Way of the Four Elements riding the generic subclass-choice machinery + * (#899/#1503): resources.subclassChoices count progression across levels, + * and crossEditionRejection (#1345) for a 2014-tagged discipline optionId + * supplied by a 2024 character. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import supertest from "supertest"; + +import { app } from "@/test-support/app-server.js"; +import { Prisma } from "@/generated/prisma/client.js"; +import { prisma } from "@/lib/core/prisma.js"; +import { ensureTestOwner } from "@/test-support/owner.js"; +import { authCookie } from "@/test-support/auth.js"; + +const OWNER_ID = "owner-discipline-subclass-choice"; +let COOKIE: string; +const FIXTURE_ID = "test-discipline-subclass-choice-1"; + +// XP thresholds: L3=900, L6=14000, L11=85000, L17=225000. +const XP_L3 = 900; +const XP_L6 = 14000; +const XP_L11 = 85000; +const XP_L17 = 225000; + +const BASE = { + id: FIXTURE_ID, + name: "Discipline Subclass Choice Test Monk", + alignment: "Neutral", + initiativeBonus: 3, + speed: 40, + hitPoints: { current: 80, max: 80, temp: 0 }, + hitDice: { total: 17, die: "d8" }, + abilityScores: { strength: 10, dexterity: 16, constitution: 12, intelligence: 10, wisdom: 16, charisma: 10 }, + savingThrowProficiencies: ["strength", "dexterity"], + skills: ["stealth"], + toolProficiencies: [], + currency: { cp: 0, sp: 0, gp: 0, pp: 0 }, +}; + +let classId: string; +let fangsId: string; + +function agent() { + return supertest.agent(app).set("Cookie", COOKIE); +} + +async function createMonk(experiencePoints: number, edition: "EDITION_2014" | "EDITION_2024", subclass: string | null) { + await prisma.character.create({ + data: { + ...BASE, + experiencePoints, + ownerId: OWNER_ID, + rulesEdition: edition, + resources: Prisma.JsonNull, + classEntries: { create: [{ name: "monk", subclass, classId, position: 0 }] }, + }, + }); +} + +describe("resources.subclassChoices — fourElementsDisciplines count progression (#1503)", () => { + beforeAll(async () => { + const cls = await prisma.characterClass.upsert({ + where: { name: "Discipline Subclass Choice Test Class" }, + create: { + name: "Discipline Subclass Choice Test Class", + hitDie: "d8", + savingThrows: ["strength", "dexterity"], + skillChoiceCount: 2, + skillChoices: ["acrobatics", "stealth"], + isSpellcaster: false, + }, + update: {}, + }); + classId = cls.id; + fangsId = (await prisma.grantedAbility.findFirstOrThrow({ where: { name: "Fangs of the Fire Snake" } })).id; + }); + + afterAll(async () => { + await prisma.characterClass.deleteMany({ where: { name: "Discipline Subclass Choice Test Class" } }); + }); + + beforeEach(async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); + }); + + afterEach(async () => { + await prisma.character.deleteMany({ where: { id: FIXTURE_ID } }); + }); + + it("reports count 1/2/3/4 at L3/6/11/17", async () => { + for (const [xp, expectedCount] of [[XP_L3, 1], [XP_L6, 2], [XP_L11, 3], [XP_L17, 4]] as const) { + await createMonk(xp, "EDITION_2014", "way of the four elements"); + const res = await agent().get(`/api/characters/${FIXTURE_ID}`); + expect(res.status).toBe(200); + const choice = (res.body.resources.subclassChoices as { key: string; count: number }[]).find( + (c) => c.key === "fourElementsDisciplines", + ); + expect(choice?.count, `L${xp}`).toBe(expectedCount); + await prisma.character.deleteMany({ where: { id: FIXTURE_ID } }); + } + }); + + // #1345: the choose-N `count`/`choices` declaration (monk.ts) is + // edition-INVARIANT (SubclassChoice.count takes no edition param, per its + // own doc comment) — only the seeded OPTION rows are edition-tagged. So the + // adversarial case this guards is a character whose subclass string reads + // "way of the four elements" (making the choice resolve) while its OWN + // rulesEdition is 2024 (a forged request or stale-migration state, never a + // state the UI itself can reach) — crossEditionRejection still catches the + // mismatched OPTION at that point, independent of the choice-availability + // check above it. + it("(#1345) rejects a 2014-tagged discipline optionId against a mismatched EDITION_2024 character", async () => { + await createMonk(XP_L3, "EDITION_2024", "way of the four elements"); + const res = await agent() + .post(`/api/characters/${FIXTURE_ID}/resources/transactions`) + .send({ operations: [{ type: "learnSubclassChoice", choiceKey: "fourElementsDisciplines", optionId: fangsId }] }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/2014 rules/); + expect(res.body.error).toMatch(/2024 rules/); + }); + + it("a 2014 character CAN learn the same discipline (sanity: the rejection above is edition-specific, not universal)", async () => { + await createMonk(XP_L3, "EDITION_2014", "way of the four elements"); + const res = await agent() + .post(`/api/characters/${FIXTURE_ID}/resources/transactions`) + .send({ operations: [{ type: "learnSubclassChoice", choiceKey: "fourElementsDisciplines", optionId: fangsId }] }); + expect(res.status).toBe(200); + expect(res.body.resources.choicesKnown.fourElementsDisciplines).toHaveLength(1); + }); +}); diff --git a/backend/src/routes/character/__tests__/level-up-transaction.test.ts b/backend/src/routes/character/__tests__/level-up-transaction.test.ts index e276ff7c..6091f4ee 100644 --- a/backend/src/routes/character/__tests__/level-up-transaction.test.ts +++ b/backend/src/routes/character/__tests__/level-up-transaction.test.ts @@ -1611,6 +1611,92 @@ describe("POST …/level-up/transactions — subclassChoice validator messages", }); }); +// #1503: Way of the Four Elements riding the level-up ceremony end-to-end — +// the ceremony's own STEP_OP_BUILDERS/domain-dispatch wiring, not just the +// pure validator (already covered unit-level in level-up-submission.test.ts). +describe("POST …/level-up/transactions — Way of the Four Elements disciplines (#1503)", () => { + it("2→3: picking the subclass + learning the free discipline pick lands both under one batch", async () => { + const monk = await prisma.characterClass.findFirstOrThrow({ where: { name: "Monk" } }); + const fourElements = await prisma.subclass.findFirstOrThrow({ where: { name: "Way of the Four Elements", classId: monk.id } }); + const fangs = await prisma.grantedAbility.findFirstOrThrow({ where: { name: "Fangs of the Fire Snake", source: "discipline" } }); + await prisma.character.create({ + data: { + ...BASE, + ownerId: OWNER_ID, + id: "lvtx-four-elements-3", + name: "LevelUpTx Four Elements 3", + rulesEdition: "EDITION_2014", + experiencePoints: 900, // monk level 3 threshold; hitDice.total 2 → 1 pending + hitPoints: { current: 16, max: 16, temp: 0, deathSaves: { successes: 0, failures: 0 } }, + hitDice: { total: 2, die: "d8", spent: 0 }, + abilityScores: { strength: 10, dexterity: 16, constitution: 12, intelligence: 10, wisdom: 16, charisma: 10 }, + spellcasting: Prisma.JsonNull, + classEntries: { create: [{ name: "monk", subclass: null, classId: monk.id, position: 0, level: 2 }] }, + }, + }); + const entry = await prisma.characterClassEntry.findFirstOrThrow({ where: { characterId: "lvtx-four-elements-3" } }); + + const res = await post("lvtx-four-elements-3", { + target: { kind: "existing", classEntryId: entry.id }, + hp: { method: "average" }, + subclassId: fourElements.id, + subclassChoices: [{ type: "learnSubclassChoice", choiceKey: "fourElementsDisciplines", optionId: fangs.id }], + }); + expect(res.status).toBe(200); + expect(res.body.resources.choicesKnown.fourElementsDisciplines).toHaveLength(1); + expect(res.body.resources.choicesKnown.fourElementsDisciplines[0].optionId).toBe(fangs.id); + expect((res.body.availableActions as { key: string }[]).some((a) => a.key === "castDiscipline")).toBe(true); + expect(await distinctBatchIds("lvtx-four-elements-3")).toHaveLength(1); + }); + + it("5→6: a swap (2 learns + 1 forget netting to the step's count 1) commits atomically", async () => { + const monk = await prisma.characterClass.findFirstOrThrow({ where: { name: "Monk" } }); + const fourElementsSub = await prisma.subclass.findFirstOrThrow({ where: { name: "Way of the Four Elements", classId: monk.id } }); + const fangs = await prisma.grantedAbility.findFirstOrThrow({ where: { name: "Fangs of the Fire Snake", source: "discipline" } }); + const water = await prisma.grantedAbility.findFirstOrThrow({ where: { name: "Water Whip", source: "discipline" } }); + const river = await prisma.grantedAbility.findFirstOrThrow({ where: { name: "Shape the Flowing River", source: "discipline" } }); + await prisma.character.create({ + data: { + ...BASE, + ownerId: OWNER_ID, + id: "lvtx-four-elements-6", + name: "LevelUpTx Four Elements 6", + rulesEdition: "EDITION_2014", + experiencePoints: 14000, // monk level 6 threshold; hitDice.total 5 → 1 pending + hitPoints: { current: 34, max: 34, temp: 0, deathSaves: { successes: 0, failures: 0 } }, + hitDice: { total: 5, die: "d8", spent: 0 }, + abilityScores: { strength: 10, dexterity: 16, constitution: 12, intelligence: 10, wisdom: 16, charisma: 10 }, + spellcasting: Prisma.JsonNull, + resources: { + used: {}, + maneuversKnown: [], + toolProficienciesKnown: [], + choicesKnown: { fourElementsDisciplines: [{ id: "e-fangs", optionId: fangs.id, name: fangs.name, description: fangs.description }] }, + advancements: [], + } as unknown as Prisma.InputJsonValue, + classEntries: { + create: [{ name: "monk", subclass: "way of the four elements", subclassId: fourElementsSub.id, classId: monk.id, position: 0, level: 5 }], + }, + }, + }); + const entry = await prisma.characterClassEntry.findFirstOrThrow({ where: { characterId: "lvtx-four-elements-6" } }); + + const res = await post("lvtx-four-elements-6", { + target: { kind: "existing", classEntryId: entry.id }, + hp: { method: "average" }, + subclassChoices: [ + { type: "learnSubclassChoice", choiceKey: "fourElementsDisciplines", optionId: water.id }, + { type: "learnSubclassChoice", choiceKey: "fourElementsDisciplines", optionId: river.id }, + ], + subclassChoicesForgotten: [{ type: "forgetSubclassChoice", choiceKey: "fourElementsDisciplines", entryId: "e-fangs" }], + }); + expect(res.status).toBe(200); + const known = res.body.resources.choicesKnown.fourElementsDisciplines as { optionId: string }[]; + expect(known.map((k) => k.optionId).sort()).toEqual([river.id, water.id].sort()); + expect(await distinctBatchIds("lvtx-four-elements-6")).toHaveLength(1); + }); +}); + // #1131: cantrip progression through the ceremony. Warlock gains its 3rd cantrip // and a prepared spell at level 4 (plus an ASI), so the newSpells step now carries // a cantrip pick alongside the leveled pick. diff --git a/backend/src/routes/character/level-up.ts b/backend/src/routes/character/level-up.ts index f63723fe..0ad18985 100644 --- a/backend/src/routes/character/level-up.ts +++ b/backend/src/routes/character/level-up.ts @@ -22,6 +22,7 @@ import { InvalidSpellcastingOperationError } from "@/lib/spellcasting/spellcasti import { makeTransactionsEndpoint } from "@/lib/http/transactions-endpoint.js"; import { takeAsiOpSchema, takeFeatOpSchema } from "@/routes/character/advancement.js"; import { + forgetSubclassChoiceOpSchema, learnManeuverOpSchema, learnToolProficiencyOpSchema, learnSubclassChoiceOpSchema, @@ -149,6 +150,9 @@ const levelUpSubmissionSchema = z.object({ maneuvers: z.array(learnManeuverOpSchema).optional(), toolProficiencies: z.array(learnToolProficiencyOpSchema).optional(), subclassChoices: z.array(learnSubclassChoiceOpSchema).optional(), + // #1503: a choose-N swap (e.g. Way of the Four Elements) — validated + // against its step's meta.canSwap by assertSubclassChoiceForgets. + subclassChoicesForgotten: z.array(forgetSubclassChoiceOpSchema).optional(), spellsLearned: z.array(learnSpellOpSchema).optional(), cantripsLearned: z.array(learnSpellOpSchema).optional(), spellsForgotten: z.array(forgetSpellOpSchema).optional(), diff --git a/backend/src/routes/character/resources.ts b/backend/src/routes/character/resources.ts index a23d0431..0f77a40d 100644 --- a/backend/src/routes/character/resources.ts +++ b/backend/src/routes/character/resources.ts @@ -70,7 +70,11 @@ export const learnSubclassChoiceOpSchema = z.object({ custom: z.object({ name: z.string().min(1), description: z.string().min(1) }).optional(), }); -const forgetSubclassChoiceOpSchema = z.object({ +// Exported (#1503) so the level-up ceremony's own submission schema +// (routes/character/level-up.ts) can reuse it verbatim for +// subclassChoicesForgotten — same "one op schema, two call sites" pattern as +// learnSubclassChoiceOpSchema above. +export const forgetSubclassChoiceOpSchema = z.object({ type: z.literal("forgetSubclassChoice"), choiceKey: z.string().min(1), entryId: z.string().min(1), diff --git a/backend/src/routes/character/subclass-choices.ts b/backend/src/routes/character/subclass-choices.ts index 15ac8ad4..b7c47bb7 100644 --- a/backend/src/routes/character/subclass-choices.ts +++ b/backend/src/routes/character/subclass-choices.ts @@ -3,6 +3,7 @@ import { Router } from "express"; import { prisma } from "@/lib/core/prisma.js"; import { requireEditionOr400 } from "@/lib/http/parse-edition-param.js"; import { resolveEditionCatalog, withEditionOrShared } from "@/lib/rules/catalog-edition.js"; +import { readAbilityCost } from "@/lib/spellcasting/ability-cost.js"; export const subclassChoicesRouter = Router({ mergeParams: true }); @@ -14,6 +15,11 @@ export const subclassChoicesRouter = Router({ mergeParams: true }); // many is carried by the serialized character's resources.subclassChoices; this // route supplies the pickable options. Alphabetical. // +// `cost`/`alwaysKnown` (#1503): additive projection fields so a ki/focus-cost +// source (today: "discipline") can offer amounts in the picker — every other +// source's rows carry no cost columns, so readAbilityCost resolves them to +// `{ kind: "none" }` and alwaysKnown reads its column default (false). +// // Mounted top-level, so `?edition=` is REQUIRED and a cross-edition row is // omitted SILENTLY (#1412) — both for the reasons spelled out at maneuversRouter, // including the deliberate asymmetry with crossEditionRejection: a list read has @@ -35,6 +41,8 @@ subclassChoicesRouter.get("/:source", async (req, res) => { name: row.name, description: row.description, minLevel: row.minLevel, + alwaysKnown: row.alwaysKnown, + cost: readAbilityCost(row), })), ); }); diff --git a/docs/deployment.md b/docs/deployment.md index b6491df3..fe4b9c6a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -105,7 +105,7 @@ docker compose exec -T db psql -U character_sheet -d character_sheet -c \ # CREATE TYPE "_new" AS ENUM (...) list. e.g.: docker compose exec -T db psql -U character_sheet -d character_sheet -c \ "SELECT type, count(*) FROM \"CharacterEvent\" - WHERE type::text IN ('learnDiscipline','castDiscipline') GROUP BY type;" + WHERE type::text IN ('learnDiscipline','disciplinesReconciled') GROUP BY type;" # 4. Remap them to a surviving value, or delete them — an explicit decision, # recorded. Deleting a CharacterEvent cascades its CharacterEventField rows diff --git a/packages/contracts/src/ability-ops.ts b/packages/contracts/src/ability-ops.ts index c6f0cf77..d5e61939 100644 --- a/packages/contracts/src/ability-ops.ts +++ b/packages/contracts/src/ability-ops.ts @@ -36,6 +36,25 @@ export const castShadowArtOpSchema = z.object({ }); export type CastShadowArtOperation = z.infer; +/** + * Cast a known Way of the Four Elements discipline (2014, #1503). `entryId` is + * the KNOWN entry's id (choicesKnown["fourElementsDisciplines"][].id, not the + * catalog GrantedAbility.id — mirrors castManeuver's entryId). `requestedKi` + * overspends above the discipline's base ki cost to scale its damage dice + * (EffectScaling "poolStep"); omitted spends the base cost. `roll` is the + * client-computed damage total for a discipline that deals damage (mirrors + * castSpell/castElementalBurst: the client rolls its own supernatural effect, + * trusted server-side) — omitted for a discipline with no damage roll. + */ +export const castDisciplineOpSchema = z.object({ + type: z.literal("castDiscipline"), + entryId: z.string().min(1), + requestedKi: z.number().int().positive().optional(), + roll: z.number().nonnegative().optional(), +}); +export type CastDisciplineOperation = z.infer; +export type DisciplineOperation = CastDisciplineOperation; + /** * Activate Cloak of Shadows (L17): spend 3 focus, become invisible. No catalog * id — unlike castShadowArt this is one fixed feature, not a granted-ability row. diff --git a/packages/shared-types/src/effects.ts b/packages/shared-types/src/effects.ts index c3383ddc..c647de9b 100644 --- a/packages/shared-types/src/effects.ts +++ b/packages/shared-types/src/effects.ts @@ -9,9 +9,12 @@ export type EffectType = "damage" | "heal" | "utility" | "buff"; // How the dice count grows: cantrips scale by character level, leveled spells by -// slot upcast steps. +// slot upcast steps, a pool-fuelled ability (e.g. a Way of the Four Elements +// discipline, #1503) by ki/focus spent above its base cost — the generalised +// successor to the pre-#1373 disciplines engine's discipline-only "focus" mode, +// now usable by any pool-cost GrantedAbility (readAbilityCost's effectiveStep). export interface EffectScaling { - mode: "none" | "slotUpcast" | "cantripLevel"; + mode: "none" | "slotUpcast" | "cantripLevel" | "poolStep"; dicePerStep?: number; } diff --git a/scripts/check-catalog-id-edition-guard.sh b/scripts/check-catalog-id-edition-guard.sh index ad207dd6..b44d9b3a 100755 --- a/scripts/check-catalog-id-edition-guard.sh +++ b/scripts/check-catalog-id-edition-guard.sh @@ -47,6 +47,7 @@ backend/src/lib/classes/resources.ts:resolveChoiceOption:guarded by crossEdition backend/src/lib/classes/shadow-arts.ts:applyCastShadowArt:guarded by crossEditionRejection (GrantedAbility, #1345 Chunk 5) backend/src/lib/classes/channel-divinity.ts:resolveChannelDivinityCast:guarded by crossEditionRejection (GrantedAbility, #1345 Chunk 5) backend/src/lib/classes/maneuvers.ts:loadManeuver:persisted id, deliberately unguarded — see the why-comment at loadManeuver (#1345 R2) +backend/src/lib/classes/disciplines.ts:loadKnownDiscipline:persisted id, deliberately unguarded — same shape as loadManeuver (#1345 R2), see the why-comment at loadKnownDiscipline (#1503) backend/src/lib/leveling/level-up-transaction.ts:resolvePickedSubclass:guarded by crossEditionRejection (Subclass, #1414) backend/src/routes/character/level-up.ts:pickedGrantSource:reachable only after resolveLevelUpContext admitted the id — see the why-comment at pickedGrantSource (#1414)" diff --git a/scripts/check-class-ts-migration.sh b/scripts/check-class-ts-migration.sh index 42b786c5..32ee70e1 100755 --- a/scripts/check-class-ts-migration.sh +++ b/scripts/check-class-ts-migration.sh @@ -36,7 +36,7 @@ NOT_YET_MIGRATED="bard cleric druid monk paladin ranger sorcerer warlock wizard" # check below, which fails loudly the moment a file in that directory is # neither here nor in ALL_CLASSES, rather than silently scanning it as # "migrated" (a thirteenth class's module would otherwise land unclassified). -NON_CLASS_MODULES="ability-registry actions activation-requires channel-divinity class class-feature-rows class-features feature-rows-select focus-cast hand-of-harm hand-of-ultimate-mercy maneuver-effect maneuvers open-hand-technique quivering-palm registry resources resources-state shadow-arts sneak-attack stunning-strike subclass-slug types warrior-of-elements" +NON_CLASS_MODULES="ability-registry actions activation-requires channel-divinity class class-feature-rows class-features disciplines feature-rows-select focus-cast hand-of-harm hand-of-ultimate-mercy maneuver-effect maneuvers open-hand-technique quivering-palm registry resources resources-state shadow-arts sneak-attack stunning-strike subclass-slug types warrior-of-elements" # Reverse check: every backend/src/lib/classes/*.ts file's basename must be # classified as EITHER a class (ALL_CLASSES) or shared infrastructure From 1d2786949737d9a608a3d21d41133d197d6604fd Mon Sep 17 00:00:00 2001 From: Steffen Andersland Date: Wed, 5 Aug 2026 06:45:22 -0400 Subject: [PATCH 2/2] fix(monk): Fist of Unbroken Air/Water Whip missing saveEffect: half (#1503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-review caught a rules-accuracy bug: both disciplines' own PHB'14 p.81 descriptions say "half damage on a success", but their seed rows omitted `saveEffect: "half"` — catalogEffectSpec resolved `saveEffect: null`, so a successful save wrongly dealt FULL damage. Every other save-for-half discipline in the file already had the field. Audited every remaining damage discipline's saveEffect/attackType/ saveAbility/dice fields against its own description text; no other mismatches found. Added a general regression guard (every save-based damage discipline must resolve saveEffect "half") plus an end-to-end resolution test through disciplineEffectSpec for the two named rows, so this class of field/text mismatch can't reappear silently. Also tightened castDisciplineOpSchema's `roll` validator from `.nonnegative()` to `.positive()` (nit): 0 is never a legitimate damage roll (minimum die result is 1) and every sibling roll field in the same file (castElementalBurst/triggerQuiveringPalm/dealHandOfHarm/ useHandOfUltimateMercy) already uses `.positive()`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn --- .../__tests__/disciplines-content.test.ts | 59 +++++++++++++++++++ backend/prisma/seed/disciplines.ts | 2 + .../lib/classes/__tests__/disciplines.test.ts | 38 ++++++++++++ packages/contracts/src/ability-ops.ts | 8 ++- 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/seed/__tests__/disciplines-content.test.ts diff --git a/backend/prisma/seed/__tests__/disciplines-content.test.ts b/backend/prisma/seed/__tests__/disciplines-content.test.ts new file mode 100644 index 00000000..aeaf853d --- /dev/null +++ b/backend/prisma/seed/__tests__/disciplines-content.test.ts @@ -0,0 +1,59 @@ +// Content-correctness audit for the discipline catalog (#1503 review fix): +// PHB'14 p.81 every save-based damage discipline explicitly halves damage on +// a successful save ("takes half damage on a success" / "half on a +// success") — Fist of Unbroken Air and Water Whip's own DESCRIPTIONS already +// said so, but their seed rows omitted `saveEffect: "half"`, so +// catalogEffectSpec (lib/combat/effects.ts) resolved `saveEffect: null` and a +// successful save would have wrongly dealt FULL damage. Asserted as a +// general invariant (not just the two named rows) so the same field/text +// mismatch can't reappear on a future discipline. +// +// Lives under prisma/seed/__tests__ (not backend/src/**) because it imports +// DISCIPLINES directly — backend/tsconfig.json's `rootDir: "src"` makes a +// src file importing anything under prisma/ a compile error (TS6059), the +// same constraint literal-fixture-parity.test.ts's own header documents. +import { describe, expect, it } from "vitest"; + +import { DISCIPLINES } from "../disciplines.js"; + +describe("discipline catalog content audit (#1503 review fix)", () => { + it("every save-based damage discipline resolves saveEffect \"half\" (PHB'14 p.81: all are save-for-half)", () => { + const saveForDamage = DISCIPLINES.filter((d) => d.effectKind === "damage" && d.attackType === "save"); + // Anti-vacuity: today's catalog has 8 such rows (Fist of Four Thunders, + // Fist of Unbroken Air, Sweeping Cinder Strike, Water Whip, Gong of the + // Summit, Flames of the Phoenix, Breath of Winter, River of Hungry + // Flame) — a filter that stopped matching any of them would make this + // test pass by iterating nothing. + expect(saveForDamage.length).toBeGreaterThanOrEqual(8); + const missing = saveForDamage.filter((d) => d.saveEffect !== "half").map((d) => d.name); + expect(missing, `save-for-damage discipline(s) missing saveEffect "half": ${missing.join(", ")}`).toEqual([]); + }); + + it("Fist of Unbroken Air and Water Whip specifically carry saveEffect \"half\" (the two rows this review caught)", () => { + for (const name of ["Fist of Unbroken Air", "Water Whip"]) { + const row = DISCIPLINES.find((d) => d.name === name); + expect(row, name).toBeDefined(); + expect(row!.saveEffect, name).toBe("half"); + } + }); + + it("Fangs of the Fire Snake (the one attack-roll, not save, damage discipline) carries no saveEffect", () => { + const row = DISCIPLINES.find((d) => d.name === "Fangs of the Fire Snake")!; + expect(row.attackType).toBe("attack"); + expect(row.saveEffect).toBeUndefined(); + }); + + it("every damageType/effectDiceCount/effectDiceFaces/saveAbility field is present exactly when effectKind is \"damage\"", () => { + for (const d of DISCIPLINES) { + if (d.effectKind === "damage") { + expect(d.effectDiceCount, d.name).toBeGreaterThan(0); + expect(d.effectDiceFaces, d.name).toBeGreaterThan(0); + expect(d.damageType, d.name).toBeTruthy(); + } else { + expect(d.effectDiceCount, d.name).toBeUndefined(); + expect(d.effectDiceFaces, d.name).toBeUndefined(); + expect(d.damageType, d.name).toBeUndefined(); + } + } + }); +}); diff --git a/backend/prisma/seed/disciplines.ts b/backend/prisma/seed/disciplines.ts index 0ea21a44..3ca41333 100644 --- a/backend/prisma/seed/disciplines.ts +++ b/backend/prisma/seed/disciplines.ts @@ -107,6 +107,7 @@ export const DISCIPLINES: DisciplineSeed[] = [ damageType: "bludgeoning", attackType: "save", saveAbility: "strength", + saveEffect: "half", description: "As an action, spend 2 ki: choose a creature within 30 ft. It makes a Strength save, taking 3d10 bludgeoning damage (plus 1d10 per additional ki spent, up to your per-cast ki cap), being pushed 20 ft away, and knocked prone on a failure; on a success it takes half damage and suffers neither push nor prone. PHB'14 p.81.", }, @@ -163,6 +164,7 @@ export const DISCIPLINES: DisciplineSeed[] = [ damageType: "bludgeoning", attackType: "save", saveAbility: "dexterity", + saveEffect: "half", description: "As an action, spend 2 ki: choose a creature you can see within 30 ft. It makes a Dexterity save, taking 3d10 bludgeoning damage (plus 1d10 per additional ki spent, up to your per-cast ki cap) and — your choice — being knocked prone or pulled up to 25 ft toward you on a failure; on a success it takes half damage and suffers neither. PHB'14 p.81.", }, diff --git a/backend/src/lib/classes/__tests__/disciplines.test.ts b/backend/src/lib/classes/__tests__/disciplines.test.ts index 63c7a9c1..bd3955ae 100644 --- a/backend/src/lib/classes/__tests__/disciplines.test.ts +++ b/backend/src/lib/classes/__tests__/disciplines.test.ts @@ -73,4 +73,42 @@ describe("disciplineEffectSpec", () => { expect(spec.dice).toBeUndefined(); expect(spec.effectType).toBe("utility"); }); + + // #1503 review fix: Fist of Unbroken Air and Water Whip both deal half + // damage on a successful save per their own PHB'14 p.81 descriptions, but + // their seed rows omitted `saveEffect: "half"` — catalogEffectSpec would + // then resolve `saveEffect: null` and a successful save would wrongly deal + // FULL damage. Exercises the actual runtime resolution path the cast + // handler uses (disciplineEffectSpec -> catalogEffectSpec -> + // readEffectSpec's saveEffect passthrough), not just the raw seed row — + // see prisma/seed/__tests__/disciplines-content.test.ts for the seed-data- + // level version of this same assertion (and the general invariant over + // every save-based damage discipline). + it("resolves saveEffect \"half\" end-to-end for Fist of Unbroken Air and Water Whip", () => { + const unbrokenAir = disciplineEffectSpec({ + name: "Fist of Unbroken Air", + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 10, + damageType: "bludgeoning", + attackType: "save", + saveAbility: "strength", + saveEffect: "half", + }); + expect(unbrokenAir.saveEffect).toBe("half"); + + const waterWhip = disciplineEffectSpec({ + name: "Water Whip", + costPerStep: 1, + effectKind: "damage", + effectDiceCount: 3, + effectDiceFaces: 10, + damageType: "bludgeoning", + attackType: "save", + saveAbility: "dexterity", + saveEffect: "half", + }); + expect(waterWhip.saveEffect).toBe("half"); + }); }); diff --git a/packages/contracts/src/ability-ops.ts b/packages/contracts/src/ability-ops.ts index d5e61939..f22edc70 100644 --- a/packages/contracts/src/ability-ops.ts +++ b/packages/contracts/src/ability-ops.ts @@ -50,7 +50,13 @@ export const castDisciplineOpSchema = z.object({ type: z.literal("castDiscipline"), entryId: z.string().min(1), requestedKi: z.number().int().positive().optional(), - roll: z.number().nonnegative().optional(), + // .positive(), not .nonnegative() — matches every sibling roll field in + // this file (castElementalBurst/triggerQuiveringPalm/dealHandOfHarm/ + // useHandOfUltimateMercy). 0 is never legitimate: the minimum roll on any + // discipline's dice (e.g. 1d10) is 1, and disciplines.ts's own server-side + // check already rejects roll <= 0 for a damage discipline — this just + // catches the same rule one layer earlier, with a clearer validation error. + roll: z.number().positive().optional(), }); export type CastDisciplineOperation = z.infer; export type DisciplineOperation = CastDisciplineOperation;