From a5ca96cdf253e64ec0fcdbec30420233add0bd9f Mon Sep 17 00:00:00 2001 From: Steffen Andersland Date: Tue, 4 Aug 2026 21:50:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(monk):=202014=20Way=20of=20Shadow=20?= =?UTF-8?q?=E2=80=94=20four-spell=202-ki=20Shadow=20Arts,=20Cloak=20of=20S?= =?UTF-8?q?hadows=20at=20L11,=20Opportunist=20(#1502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4/7 of the 2014 Monk epic (#1313), depends on #1500 (merged). Way of Shadow is not in SRD 5.1 — content transcribed from PHB'14 pp.79-80. - New Subclass row "Way of Shadow" (EDITION_2014, slug monk-way-of-shadow), a DISTINCT slug from Warrior of Shadow (now retagged EDITION_2024) — added to SUBCLASS_SLUGS/SUBCLASS_IDENTITY and monk.ts's subclasses map. - shadow-arts.ts (seed): widened ShadowArtSeed with per-row costPoolKey/ costBase (the field that genuinely forks); authored the real four-spell menu (Darkness/Darkvision/Pass without Trace/Silence, 2 ki each, PHB'14 pp.79-80) plus the retagged EDITION_2024 Darkness row. seedShadowArts reads cost fields per-row instead of hardcoding focus/1. - monk-features.ts: Warrior of Shadow's 4 rows tagged EDITION_2024; new WAY_OF_SHADOW_RAW (4 EDITION_2014 rows: Shadow Arts L3, Shadow Step L6 with no free unarmed strike, Cloak of Shadows L11 with no resource cost, Opportunist L17). - actions.ts: shadowStep/shadowArts/cloakOfShadows (2024) tagged EDITION_2024 explicitly; four new EDITION_2014 rows under the SAME action keys (disambiguated by edition + grantSubclassSlugs: ["monk-way-of-shadow"]) plus a new "opportunist" key (no 2024 counterpart). - shadow-arts.ts (lib): shadowArtEffectSpec's concentration is now name-keyed (every Shadow Art concentrates except "Shadow Arts: Darkvision" — verified against real PHB'14 spell durations: Darkness/Pass without Trace/Silence are all "Concentration, up to ..."; Darkvision is a flat 8-hour duration). activateCloakOfShadows is edition-branched: 2014 costs nothing (AbilityCost {kind:"none"}, not a fake 0-base pool spend), 2024 still costs 3 focus. Gate-rejection error text now names the edition-correct subclass/level. - subclass-granted-spells.ts: Way of Shadow's own Minor Illusion grant (EDITION_2014, L3, Wisdom), mirroring the 2024 grant. RULES-ACCURACY CORRECTIONS against the issue as filed (verified against PHB'14 text, not guessed): - Cloak of Shadows is PHB'14's 11th-level Monastic Tradition feature, not 13th — 2014 monk subclasses gate at 3/6/11/17 uniformly; no monk subclass feature exists at 13. Implemented at L11. - Concentration: the issue's brief said "only darkness and silence concentrate" — Pass without Trace is also a concentration spell ("Duration: Concentration, up to 1 hour"); only Darkvision (8 hours, no concentration prefix) is exempt. Implemented as Darkness/Pass without Trace/Silence concentrate, Darkvision does not. TDD: red tests first for the DERIVED_ACTIONS gates (actions.test.ts, would have failed with "expected undefined to be defined" pre-implementation, and did), then made green. Backend suite green (364 test files, 4418 tests, 14 pre-existing skips); typecheck and lint clean across all workspaces; `prisma db seed` verified idempotent (ran twice, second run silent — no stale-row drops); assertEverySubclassEditionPopulated and scripts/check-catalog-id-edition-guard.sh both pass; fallow audit verdict "pass" (0 dead code, 0 new complexity, 0 new duplication). Fixed the two stale pre-#1415 comments in granted-ability-fork-reseed.test.ts per the issue's own scope item, and added a new describe block proving the REAL SHADOW_ARTS catalog (not a fixture) round-trips two seed-equivalent runs. seed-data.test.ts's SHADOW_ARTS uniqueness test now keys on (name, edition) instead of name alone, matching CHANNEL_DIVINITIES/FEATS' own convention. Closes #1502 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn --- backend/prisma/seed.ts | 24 +- .../granted-ability-fork-reseed.test.ts | 60 +- .../seed/__tests__/monk-2024-content.test.ts | 12 +- .../prisma/seed/__tests__/seed-data.test.ts | 19 +- backend/prisma/seed/monk-features.ts | 120 +++- backend/prisma/seed/shadow-arts.ts | 70 +- .../prisma/seed/subclass-granted-spells.ts | 13 + backend/prisma/seed/subclasses.ts | 12 + .../class-features-snapshot.test.ts.snap | 614 ++++++++++++++++++ .../src/lib/classes/__tests__/actions.test.ts | 106 ++- .../__tests__/class-subclasses.fixture.ts | 2 +- .../classes/__tests__/feature-edition.test.ts | 31 +- .../__tests__/test-feature-rows.fixture.ts | 36 +- backend/src/lib/classes/actions.ts | 96 ++- backend/src/lib/classes/monk.ts | 11 + backend/src/lib/classes/shadow-arts.ts | 115 +++- backend/src/lib/classes/subclass-slug.ts | 10 +- .../__tests__/shadow-arts-cast.test.ts | 202 +++++- 18 files changed, 1398 insertions(+), 155 deletions(-) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 2d7a7bce..12cb58cf 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -159,13 +159,15 @@ async function seedManeuvers(prisma: PrismaClient) { } } -// Seed the Shadow Arts catalog — upsert by (name, edition). Flat 1-focus, no scaling -// (2024 rewrite, #1246: was flat 2-focus across a 4-spell menu; now a single -// always-concentrating Darkness cast, so effectKind/buffTarget/buffModifier are -// fixed nulls rather than per-row fields). +// Seed the Shadow Arts catalog — upsert by (name, edition). No scaling on any +// row (2024's single Darkness cast; 2014's flat-2-ki four-spell menu, #1502), +// so effectKind/buffTarget/buffModifier stay fixed nulls rather than per-row +// fields. costPoolKey/costBase are per-row (ki/2 for 2014, focus/1 for +// 2024) — the one thing that genuinely forks; minLevel/alwaysKnown/costKind +// stay hardcoded since every row, both editions, agrees on them. async function seedShadowArts(prisma: PrismaClient) { for (const art of SHADOW_ARTS) { - const edition = art.edition ?? null; + const edition = art.edition; const data = { name: art.name, edition, @@ -174,8 +176,8 @@ async function seedShadowArts(prisma: PrismaClient) { minLevel: 3, alwaysKnown: true, costKind: "pool", - costPoolKey: "focus", - costBase: 1, + costPoolKey: art.costPoolKey, + costBase: art.costBase, costPerStep: null, effectKind: null, buffTarget: null, @@ -183,9 +185,9 @@ async function seedShadowArts(prisma: PrismaClient) { }; await upsertEditionRow(prisma.grantedAbility, { name: art.name, edition }, data, data); } - // Drop the retired 2014 rows (Silence/Pass without Trace/Darkvision) — same - // edition-partitioned staleCatalogRowsWhere seedFeats uses (#1306); source: - // "shadowArts" passed in as extraWhere so this never touches + // Drop stale catalog rows (e.g. an edition retag stranding its old row) — + // same edition-partitioned staleCatalogRowsWhere seedFeats uses (#1306); + // source: "shadowArts" passed in as extraWhere so this never touches // maneuvers/channelDivinity rows sharing the same table. // // Each row's OWN edition goes into the seeded list, not a flat null: an @@ -194,7 +196,7 @@ async function seedShadowArts(prisma: PrismaClient) { // next reseed (proven in granted-ability-fork-reseed.test.ts). const staleWhere = staleCatalogRowsWhere( "name", - SHADOW_ARTS.map((a) => ({ identity: a.name, edition: a.edition ?? null })), + SHADOW_ARTS.map((a) => ({ identity: a.name, edition: a.edition })), { source: "shadowArts" }, ); const stale = await prisma.grantedAbility.findMany({ where: staleWhere, select: { name: true } }); diff --git a/backend/prisma/seed/__tests__/granted-ability-fork-reseed.test.ts b/backend/prisma/seed/__tests__/granted-ability-fork-reseed.test.ts index fcb7eab0..f5f7b61f 100644 --- a/backend/prisma/seed/__tests__/granted-ability-fork-reseed.test.ts +++ b/backend/prisma/seed/__tests__/granted-ability-fork-reseed.test.ts @@ -20,6 +20,7 @@ import { prisma } from "@/lib/core/prisma.js"; import { upsertEditionRow } from "@/lib/rules/catalog-edition.js"; import { staleCatalogRowsWhere } from "../prune.js"; +import { SHADOW_ARTS } from "../shadow-arts.js"; const MANEUVER_NAME = "Zzz Fork Reseed Maneuver (#1415)"; const ART_NAME = "Zzz Fork Reseed Shadow Art (#1415)"; @@ -80,9 +81,12 @@ describe("the converse: an undeclared fork is pruned (#1313's remaining work)", data: { name: ART_NAME, source: "shadowArts", description: "2024", edition: "EDITION_2024" }, }); - // The shape seedShadowArts passes today: `SHADOW_ARTS.map(a => ({ identity: a.name, edition: null }))`. - // The name IS declared, but only in the null partition — the 2014/2024 - // partitions get `notIn: []`, which matches everything in them. + // A flat-null seeded list (every ShadowArtSeed.edition omitted) leaves the + // name declared only in the null partition — the 2014/2024 partitions get + // `notIn: []`, which matches everything in them and deletes both forks. + // No real seeder passes this shape any more (SHADOW_ARTS' edition is + // required, #1502) — this block stays as the CONVERSE half of the + // property below, proving the failure mode threading edition prevents. const seededAsToday = [{ identity: ART_NAME, edition: null }]; await prisma.grantedAbility.deleteMany({ where: staleCatalogRowsWhere("name", seededAsToday, { source: "shadowArts", ...ONLY_THIS_FILES_ROWS }), @@ -100,8 +104,9 @@ describe("the converse: an undeclared fork is pruned (#1313's remaining work)", data: { name: ART_NAME, source: "shadowArts", description: "2024", edition: "EDITION_2024" }, }); - // What #1313 must do: ShadowArtSeed gains `edition?: SeedEdition` and - // seedShadowArts maps `{ identity: a.name, edition: a.edition ?? null }`. + // What seedShadowArts does today (#1415/#1502): each row's OWN edition + // threads into the seeded list — `SHADOW_ARTS.map(a => ({ identity: + // a.name, edition: a.edition }))`, proven against the real catalog below. const seededWithEditions = [ { identity: ART_NAME, edition: "EDITION_2014" as const }, { identity: ART_NAME, edition: "EDITION_2024" as const }, @@ -115,6 +120,51 @@ describe("the converse: an undeclared fork is pruned (#1313's remaining work)", }); }); +// #1502: the real SHADOW_ARTS catalog (not a fixture) exercises the exact +// mechanism above end-to-end — four EDITION_2014 rows plus one EDITION_2024 +// row, "Shadow Arts: Darkness" among them exactly once per edition. Safe to +// run against the shared dev DB: reproducing seedShadowArts' own upsert-then- +// prune shape against ITS OWN real, already-seeded rows twice is exactly what +// a real `prisma db seed` run does, so this leaves the catalog in the same +// state it started in (no fixture, nothing to clean up in afterEach). +describe("the real SHADOW_ARTS catalog round-trips a reseed (#1502)", () => { + it("seeding twice leaves exactly 5 rows — 4 EDITION_2014 + 1 EDITION_2024 — with Darkness once per edition", async () => { + for (let run = 0; run < 2; run += 1) { + for (const art of SHADOW_ARTS) { + const data = { + name: art.name, + edition: art.edition, + source: "shadowArts", + description: art.description, + minLevel: 3, + alwaysKnown: true, + costKind: "pool", + costPoolKey: art.costPoolKey, + costBase: art.costBase, + costPerStep: null, + effectKind: null, + buffTarget: null, + buffModifier: null, + }; + await upsertEditionRow(prisma.grantedAbility, { name: art.name, edition: art.edition }, data, data); + } + const staleWhere = staleCatalogRowsWhere( + "name", + SHADOW_ARTS.map((a) => ({ identity: a.name, edition: a.edition })), + { source: "shadowArts" }, + ); + await prisma.grantedAbility.deleteMany({ where: staleWhere }); + } + + const rows = await prisma.grantedAbility.findMany({ where: { source: "shadowArts" } }); + expect(rows).toHaveLength(5); + expect(rows.filter((r) => r.edition === "EDITION_2014")).toHaveLength(4); + expect(rows.filter((r) => r.edition === "EDITION_2024")).toHaveLength(1); + const darkness = rows.filter((r) => r.name === "Shadow Arts: Darkness"); + expect(darkness.map((r) => r.edition).sort()).toEqual(["EDITION_2014", "EDITION_2024"]); + }); +}); + // #1229: seedChannelDivinities had NO prune at all before this issue — // retagging "Channel Divinity: Turn the Unholy" (and its two siblings) from // `edition: null` to `EDITION_2014` creates a NEW row via upsertEditionRow's diff --git a/backend/prisma/seed/__tests__/monk-2024-content.test.ts b/backend/prisma/seed/__tests__/monk-2024-content.test.ts index 7ffc4196..016043da 100644 --- a/backend/prisma/seed/__tests__/monk-2024-content.test.ts +++ b/backend/prisma/seed/__tests__/monk-2024-content.test.ts @@ -34,10 +34,11 @@ const BASE = null; const OPEN_HAND = "monk-warrior-of-the-open-hand"; const WAY_OPEN_HAND = "monk-way-of-the-open-hand"; 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"; -describe("Per-partition counts: base 17(2014)/18(2024); open hand forks into two 4-row EDITION-EXCLUSIVE subclasses (#1501); shadow 4, elements 5, mercy 6 still identical for 2014/2024 pending #1502-#1503", () => { +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)", () => { 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); @@ -50,10 +51,17 @@ describe("Per-partition counts: base 17(2014)/18(2024); open hand forks into two 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(SHADOW, edition)).toBe(4); expect(count(ELEMENTS, edition)).toBe(5); expect(count(MERCY, edition)).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. + expect(count(SHADOW, "EDITION_2014")).toBe(0); + 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); + 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); diff --git a/backend/prisma/seed/__tests__/seed-data.test.ts b/backend/prisma/seed/__tests__/seed-data.test.ts index 6012ec4a..a81d1f01 100644 --- a/backend/prisma/seed/__tests__/seed-data.test.ts +++ b/backend/prisma/seed/__tests__/seed-data.test.ts @@ -63,15 +63,16 @@ describe("SUBCLASS_GRANTED_SPELLS — referential integrity", () => { expect(grant!.castingAbility).toBe("wisdom"); }); - // #1625: the two Monk grants are PHB'24/SRD 5.2-native content on shared - // Subclass rows — untagged, they would leak to 2014 Monks once #1313/#1372 - // seed the 2014 Way of * content. Pins the tag so a content resweep can't - // silently drop it. - it("the two Monk grants are tagged EDITION_2024 (2024-native content, #1625)", () => { + // #1625: the Warrior of * grants are PHB'24/SRD 5.2-native content on their + // OWN edition-tagged Subclass rows — untagged, they would leak across + // editions. Way of Shadow's own Minor Illusion grant (#1502) joined this + // list tagged EDITION_2014, for the same reason in the other direction. + it("every Monk grant is tagged its subclass's own edition (#1625, #1502)", () => { const monkGrants = SUBCLASS_GRANTED_SPELLS.filter((g) => g.className === "Monk"); expect(monkGrants.map((g) => `${g.subclassName}::${g.spellName}::${g.edition}`).sort()).toEqual([ "Warrior of Shadow::Minor Illusion::EDITION_2024", "Warrior of the Elements::Elementalism::EDITION_2024", + "Way of Shadow::Minor Illusion::EDITION_2014", ]); }); @@ -196,8 +197,12 @@ describe("per-domain business-key uniqueness", () => { expect(duplicates(MANEUVERS.map((m) => m.name))).toEqual([]); }); - it("SHADOW_ARTS have unique names", () => { - expect(duplicates(SHADOW_ARTS.map((s) => s.name))).toEqual([]); + // Keyed on (name, edition) rather than name alone (#1415/#1502): "Shadow + // Arts: Darkness" legitimately repeats its name once per edition (a + // genuine mechanical fork — 1 focus vs 2 ki) — only a same-name/ + // same-edition collision would collapse in the DB's (name, edition) upsert. + it("SHADOW_ARTS have unique (name, edition) pairs", () => { + expect(duplicates(SHADOW_ARTS.map((s) => `${s.name}::${s.edition}`))).toEqual([]); }); // Keyed on (name, edition) rather than name alone (#1229): Nature's Wrath diff --git a/backend/prisma/seed/monk-features.ts b/backend/prisma/seed/monk-features.ts index 100c4db7..da835abf 100644 --- a/backend/prisma/seed/monk-features.ts +++ b/backend/prisma/seed/monk-features.ts @@ -11,26 +11,31 @@ // direct database calls or async write logic may live in this file. // expand() below is pure content assembly, not seeding logic. // -// SCOPE (#1675 transport, #1500 base-class rewrite, #1501 Open Hand 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 -// (this slice) forks Warrior of the Open Hand into two SEPARATE subclasses — -// "Warrior of the Open Hand" stays EDITION_2024-only (its four rows tagged in -// this 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). The -// three remaining 2024-only subclasses (Shadow, Elements, Mercy) are still -// untouched — no 2014 slug exists for any of them yet (#1502-#1503's later -// slices), so their rows stay byte-identical transcriptions pending those. +// 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. // 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). @@ -543,14 +548,15 @@ const WAY_OF_THE_OPEN_HAND_RAW: RawMonkFeature[] = [ // Shadow Arts drops the 2014 flat-2-focus/4-spell menu for a single 1-focus // Darkness cast + passive Minor Illusion/Darkvision grants; Cloak of Shadows // moves 11 -> 17 (replacing Opportunist, retired — no 2024 equivalent) and -// Improved Shadow Step fills the vacated L11 slot. Untagged here regardless -// — this migration seeds the current (post-#1246) text for BOTH editions -// (byte-identical to today); the 2014 divergence is #1500-#1503's job. +// Improved Shadow Step fills the vacated L11 slot. Tagged EDITION_2024 (#1502 +// — a real 2014 monk subclass, Way of Shadow, now exists under its OWN slug +// below, so this text is no longer a both-editions transcription). const WARRIOR_OF_SHADOW_RAW: RawMonkFeature[] = [ { subclassSlug: slug("monk-warrior-of-shadow"), name: "Shadow Arts", level: 3, + edition: "EDITION_2024", description: "You know the Minor Illusion cantrip (Wisdom). Spend 1 focus to cast Darkness without material components; you can see through the darkness you create, and while it persists you can move it up to 30 ft as a bonus action. You also have Darkvision out to 60 ft, or your Darkvision's range increases by 60 ft if you already have it.", }, @@ -558,6 +564,7 @@ const WARRIOR_OF_SHADOW_RAW: RawMonkFeature[] = [ subclassSlug: slug("monk-warrior-of-shadow"), name: "Shadow Step", level: 6, + edition: "EDITION_2024", description: "While in dim light or darkness, teleport as a bonus action to an unoccupied space you can see that is also in dim light or darkness (up to 60 ft), then make one unarmed strike as part of the same bonus action. You have advantage on the first melee attack you make before the end of the turn.", }, @@ -565,6 +572,7 @@ const WARRIOR_OF_SHADOW_RAW: RawMonkFeature[] = [ subclassSlug: slug("monk-warrior-of-shadow"), name: "Improved Shadow Step", level: 11, + edition: "EDITION_2024", description: "When you Shadow Step, you can spend 1 focus to ignore the requirement that your destination be in dim light or darkness.", }, @@ -572,11 +580,60 @@ const WARRIOR_OF_SHADOW_RAW: RawMonkFeature[] = [ subclassSlug: slug("monk-warrior-of-shadow"), name: "Cloak of Shadows", level: 17, + edition: "EDITION_2024", description: "Spend 3 focus and use your action to become invisible and able to move through other creatures and objects as if they were difficult terrain, for 1 minute or until you're incapacitated. The invisibility ends early if you attack or cast a spell. While it lasts, Flurry of Blows costs no focus.", }, ]; +// ---- Way of Shadow — PHB'14 pp.79-80 (not in SRD 5.1, #1502) --------------- +// A materially different fork from the 2024 rewrite above, not a retab: +// Shadow Arts is a flat 4-spell 2-ki menu (the per-spell catalog rows live in +// shadow-arts.ts, not here — this row is the feature TEXT); Shadow Step +// grants no free unarmed strike and never upgrades (no Improved Shadow Step — +// that's 2024-only, filling the L11 slot THIS subclass uses for Cloak of +// Shadows instead); Cloak of Shadows costs no ki and has no duration cap +// beyond "until you attack, cast a spell, or are in bright light"; Opportunist +// (no 2024 counterpart — retired there in favor of Cloak of Shadows at L17) +// returns as its own L17 feature. DISTINCT subclassSlug from Warrior of +// Shadow above (monk-way-of-shadow, not monk-warrior-of-shadow) — the two +// lineages coexist per campaign (epic #1281), never merged or substring- +// matched (#1339). +const WAY_OF_SHADOW_RAW: RawMonkFeature[] = [ + { + subclassSlug: slug("monk-way-of-shadow"), + name: "Shadow Arts", + level: 3, + edition: "EDITION_2014", + description: + "Starting when you choose this tradition at 3rd level, you can use your ki to duplicate the effects of certain spells. As an action, you can spend 2 ki points to cast darkness, darkvision, pass without trace, or silence, without providing material components. Additionally, you gain the minor illusion cantrip if you don't already know it (PHB'14 pp.79-80 — not in SRD 5.1).", + }, + { + subclassSlug: slug("monk-way-of-shadow"), + name: "Shadow Step", + level: 6, + edition: "EDITION_2014", + description: + "At 6th level, you gain the ability to step from one shadow to another. When you are in dim light or darkness, as a bonus action you can teleport up to 60 feet to an unoccupied space you can see that is also in dim light or darkness. You then have advantage on the first melee attack you make before the end of the current turn (PHB'14 p.80 — not in SRD 5.1).", + }, + { + subclassSlug: slug("monk-way-of-shadow"), + name: "Cloak of Shadows", + level: 11, + edition: "EDITION_2014", + description: + "By 11th level, you have learned to become one with the shadows. When you are in an area of dim light or darkness, you can use your action to become invisible. You remain invisible until you make an attack, cast a spell, or are in an area of bright light (PHB'14 p.80 — not in SRD 5.1).", + }, + { + subclassSlug: slug("monk-way-of-shadow"), + name: "Opportunist", + level: 17, + edition: "EDITION_2014", + description: + "Beginning at 17th level, you can exploit a creature's momentary distraction when it is hit by an attack. When a creature within 5 feet of you is hit by an attack made by a creature other than you, you can use your reaction to make a melee attack against that creature (PHB'14 p.80 — not in SRD 5.1).", + }, +]; + // ---- Warrior of Mercy — PHB'24 p.92 (not in SRD 5.2, gap-fill content, #1248) -- // None of these features call for a saving throw: Hand of Harm/Hand of // Healing/Hand of Ultimate Mercy are touch effects that land automatically @@ -700,17 +757,22 @@ const WARRIOR_OF_THE_ELEMENTS_RAW: RawMonkFeature[] = [ // 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) -// + three 2024-only subclasses still expanded to both editions pending -// #1502-#1503 (Shadow 4, Elements 5, Mercy 6 = 15 features x 2 editions = -// 30 rows) = 36 EDITION_2014 + 37 EDITION_2024 = 73 rows total (the Open -// Hand swap is a wash: 4 rows move from the shared-count column to their own -// edition-exclusive one on each side). Concatenated into class-features.ts's -// CLASS_FEATURES the same way every other literal class's export is. +// + 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. export const MONK_FEATURES: ClassFeatureSeedRow[] = [ ...MONK_BASE_RAW.flatMap(expand), ...WARRIOR_OF_THE_OPEN_HAND_RAW.flatMap(expand), ...WAY_OF_THE_OPEN_HAND_RAW.flatMap(expand), ...WARRIOR_OF_SHADOW_RAW.flatMap(expand), + ...WAY_OF_SHADOW_RAW.flatMap(expand), ...WARRIOR_OF_MERCY_RAW.flatMap(expand), ...WARRIOR_OF_THE_ELEMENTS_RAW.flatMap(expand), ]; diff --git a/backend/prisma/seed/shadow-arts.ts b/backend/prisma/seed/shadow-arts.ts index f90c1070..74a7135c 100644 --- a/backend/prisma/seed/shadow-arts.ts +++ b/backend/prisma/seed/shadow-arts.ts @@ -1,19 +1,32 @@ -// ── Shadow Arts catalog (Warrior of Shadow, #1246 — 2024 rewrite of #441) ─── -// The 2024 Shadow Arts feature has exactly one cast (the 2014 4-spell menu — -// Darkness/Silence/Pass without Trace/Darkvision — is retired): a single L3 -// GrantedAbility row, source "shadowArts", flat 1-focus, always concentrates -// (shadow-arts.ts shadowArtEffectSpec). Minor Illusion (the feature's other -// grant) is a granted cantrip, seeded in subclass-granted-spells.ts; Darkvision -// is flavor text (this app tracks no senses). +// ── Shadow Arts catalog ────────────────────────────────────────────────────── +// Warrior of Shadow (2024, PHB'24 p.91 — #1246 rewrite of #441): exactly one +// cast, a single L3 GrantedAbility row, source "shadowArts", 1 focus, always +// concentrates (shadow-arts.ts shadowArtEffectSpec). Minor Illusion (the +// feature's other grant) is a granted cantrip, seeded in +// subclass-granted-spells.ts; Darkvision is flavor text (this app tracks no +// senses). +// +// Way of Shadow (2014, PHB'14 pp.79-80 — not in SRD 5.1, #1502): "As an +// action, you can spend 2 ki points to cast darkness, darkvision, pass +// without trace, or silence, without providing material components" — four +// L3 rows, 2 ki each, the exact same-name fork #1415's (name, edition) +// widening exists for ("Shadow Arts: Darkness" now exists once per edition). +// costPoolKey/costBase genuinely differ per edition (ki/2 vs focus/1) and are +// threaded per row below; minLevel(3)/alwaysKnown(true)/costKind("pool") stay +// hardcoded in seedShadowArts since every row (both editions) agrees on them. import type { SeedEdition } from "./edition.js"; export interface ShadowArtSeed { name: string; description: string; - // Omitted = shared (NULL column, valid in both editions, #1306); only a - // mechanically diverging row forks, which #1415 made expressible. Nothing - // forks yet — #1313 will author the 2014 four-spell menu here. - edition?: SeedEdition; + // A mechanically diverging row forks (#1415 made this expressible); never + // omitted here — every row today is edition-specific (see file header). + edition: SeedEdition; + // The pool this art spends from and how much — genuinely differs per + // edition (2014's ki vs 2024's focus, 2 vs 1), unlike minLevel/alwaysKnown/ + // costKind which agree across both and stay hardcoded in seedShadowArts. + costPoolKey: "ki" | "focus"; + costBase: number; } export const SHADOW_ARTS: ShadowArtSeed[] = [ @@ -21,5 +34,40 @@ export const SHADOW_ARTS: ShadowArtSeed[] = [ name: "Shadow Arts: Darkness", description: "Spend 1 focus to cast Darkness without material components (a 15-ft sphere of magical darkness). You can see through it, and while it persists you can move it up to 30 ft as a bonus action. Requires concentration.", + edition: "EDITION_2024", + costPoolKey: "focus", + costBase: 1, + }, + { + name: "Shadow Arts: Darkness", + description: + "Spend 2 ki to cast Darkness without material components: a 15-ft-radius sphere of magical darkness spreads from a point you choose within 60 ft, lasting 10 minutes (PHB'14 pp.79-80 — not in SRD 5.1). Requires concentration.", + edition: "EDITION_2014", + costPoolKey: "ki", + costBase: 2, + }, + { + name: "Shadow Arts: Darkvision", + description: + "Spend 2 ki to cast Darkvision without material components: touch a willing creature to grant it darkvision out to 60 ft for 8 hours (PHB'14 pp.79-80 — not in SRD 5.1). No concentration.", + edition: "EDITION_2014", + costPoolKey: "ki", + costBase: 2, + }, + { + name: "Shadow Arts: Pass without Trace", + description: + "Spend 2 ki to cast Pass without Trace without material components: for 1 hour, you and companions within 30 ft gain a +10 bonus to Dexterity (Stealth) checks and can't be tracked except by magical means (PHB'14 pp.79-80 — not in SRD 5.1). Requires concentration.", + edition: "EDITION_2014", + costPoolKey: "ki", + costBase: 2, + }, + { + name: "Shadow Arts: Silence", + description: + "Spend 2 ki to cast Silence without material components: a 20-ft-radius sphere of silence spreads from a point you choose within 60 ft, lasting 10 minutes (PHB'14 pp.79-80 — not in SRD 5.1). Requires concentration.", + edition: "EDITION_2014", + costPoolKey: "ki", + costBase: 2, }, ]; diff --git a/backend/prisma/seed/subclass-granted-spells.ts b/backend/prisma/seed/subclass-granted-spells.ts index 3bb084d4..8ff2c3fa 100644 --- a/backend/prisma/seed/subclass-granted-spells.ts +++ b/backend/prisma/seed/subclass-granted-spells.ts @@ -72,6 +72,19 @@ export const SUBCLASS_GRANTED_SPELLS: SubclassGrantedSpellSeed[] = [ castingAbility: "wisdom", edition: "EDITION_2024", }, + // Way of Shadow (Monk) — Shadow Arts (PHB'14 pp.79-80 — not in SRD 5.1, + // #1502): "you gain the minor illusion cantrip if you don't already know + // it," same L3/Wisdom shape as the 2024 grant above, on its OWN + // EDITION_2014-tagged Subclass row (monk-way-of-shadow) so it never leaks + // to a 2024 Warrior of Shadow monk or vice versa. + { + className: "Monk", + subclassName: "Way of Shadow", + spellName: "Minor Illusion", + gateLevel: 3, + castingAbility: "wisdom", + edition: "EDITION_2014", + }, // Warrior of the Elements (Monk) — Manipulate Elements (L3) grants the // Elementalism cantrip (#1247, SRD 5.2 / PHB'24). EDITION_2024 for the same // reason as Warrior of Shadow above. diff --git a/backend/prisma/seed/subclasses.ts b/backend/prisma/seed/subclasses.ts index 35c204ca..d0533c43 100644 --- a/backend/prisma/seed/subclasses.ts +++ b/backend/prisma/seed/subclasses.ts @@ -220,6 +220,18 @@ export const SUBCLASSES: SubclassSeed[] = [ description: "You follow a tradition that values stealth and subtlety. You know Minor Illusion, cast Darkness for 1 focus, and see in the dark. You teleport between areas of shadow, and at higher levels can spend focus to teleport freely and, ultimately, cloak yourself in invisibility.", slug: "monk-warrior-of-shadow", + edition: "EDITION_2024", + }, + // PHB'14 pp.79-80 — not in SRD 5.1, which ships only Way of the Open Hand + // for monk (#1502). A DISTINCT slug from Warrior of Shadow above, not a + // retag: the two lineages coexist per campaign (epic #1281). + { + className: "Monk", + name: "Way of Shadow", + description: + "You follow a monastic tradition that values stealth and subtlety. You know Minor Illusion and can spend ki to duplicate Darkness, Darkvision, Pass without Trace, or Silence. You teleport between areas of shadow, and at higher levels can cloak yourself in invisibility and capitalize when an ally strikes a foe near you.", + slug: "monk-way-of-shadow", + edition: "EDITION_2014", }, { className: "Monk", 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 bc9144bd..52d6dfc6 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 @@ -8817,6 +8817,535 @@ 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 shadow 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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "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, + }, + ], + }, + "level": 20, + }, +] +`; + exports[`deriveResources snapshot — pins output for every class/subclass across all 20 levels > monk / way of the open hand 1`] = ` [ { @@ -19450,6 +19979,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 shadow 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 1dfaeea3..0afda2ff 100644 --- a/backend/src/lib/classes/__tests__/actions.test.ts +++ b/backend/src/lib/classes/__tests__/actions.test.ts @@ -1243,6 +1243,78 @@ describe("Warrior of Shadow — Shadow Arts / Cloak of Shadows catalog rows (#13 }); }); +// #1502: 2014 Way of Shadow (PHB'14 pp. 79-80 — not in SRD 5.1) reinstates the +// four-spell 2-ki Shadow Arts menu, Shadow Step without the free unarmed +// strike, Cloak of Shadows at L11 with no resource cost, and Opportunist at +// L17 — under the SAME action keys as the 2024 Warrior of Shadow rows +// (shadowArts/shadowStep/cloakOfShadows), disambiguated by `edition` + +// `grantSubclassSlugs: ["monk-way-of-shadow"]`, never a second vocabulary. +describe("Way of Shadow (2014) — Shadow Arts / Shadow Step / Cloak of Shadows / Opportunist (#1502)", () => { + const WAY: SubclassSlug = "monk-way-of-shadow"; + + it("gets shadowArts at L3, not L2, gated on 2 ki", () => { + expect(keys(deriveActions("monk", WAY, 2, [], true, "EDITION_2014"))).not.toContain("shadowArts"); + const l3 = deriveActions("monk", WAY, 3, [pool("ki", 2)], true, "EDITION_2014"); + const shadowArts = l3.find((a) => a.key === "shadowArts"); + expect(shadowArts).toBeDefined(); + expect(shadowArts?.cost).toBe("action"); + expect(shadowArts?.enabled).toBe(true); + const short = deriveActions("monk", WAY, 3, [pool("ki", 1)], true, "EDITION_2014"); + expect(short.find((a) => a.key === "shadowArts")?.enabled).toBe(false); + }); + + it("gets shadowStep at L6, not L5, free (no resourceKey), with no unarmed-strike clause", () => { + expect(keys(deriveActions("monk", WAY, 5, [], true, "EDITION_2014"))).not.toContain("shadowStep"); + const l6 = deriveActions("monk", WAY, 6, [], true, "EDITION_2014"); + const shadowStep = l6.find((a) => a.key === "shadowStep"); + expect(shadowStep).toBeDefined(); + expect(shadowStep?.cost).toBe("bonusAction"); + expect(shadowStep?.enabled).toBe(true); + expect(shadowStep?.reminder).not.toMatch(/unarmed strike/i); + }); + + it("gets cloakOfShadows at L11, not L10, with no resource cost", () => { + expect(keys(deriveActions("monk", WAY, 10, [], true, "EDITION_2014"))).not.toContain("cloakOfShadows"); + const l11 = deriveActions("monk", WAY, 11, [], true, "EDITION_2014"); + const cloak = l11.find((a) => a.key === "cloakOfShadows"); + expect(cloak).toBeDefined(); + expect(cloak?.cost).toBe("action"); + expect(cloak?.enabled).toBe(true); + }); + + it("gets opportunist at L17, not L16, as a reminder-only reaction", () => { + expect(keys(deriveActions("monk", WAY, 16, [], true, "EDITION_2014"))).not.toContain("opportunist"); + const l17 = deriveActions("monk", WAY, 17, [], true, "EDITION_2014"); + const opportunist = l17.find((a) => a.key === "opportunist"); + expect(opportunist).toBeDefined(); + expect(opportunist?.cost).toBe("reaction"); + expect(opportunist?.enabled).toBe(true); + }); + + it("is a pure reminder action — no ACTION_EFFECT_FN entry (mirrors 2014's own shadowStep)", () => { + expect(ACTION_EFFECT_FN.opportunist).toBeUndefined(); + }); + + it("none of the four rows leak to an EDITION_2024 request, even for the same slug", () => { + const asIf2024 = keys(deriveActions("monk", WAY, 20, [pool("ki", 5)], true, "EDITION_2024")); + expect(asIf2024).not.toContain("shadowArts"); + expect(asIf2024).not.toContain("shadowStep"); + expect(asIf2024).not.toContain("cloakOfShadows"); + expect(asIf2024).not.toContain("opportunist"); + }); + + it("subclass gate: the 2024 Warrior of Shadow slug never gets Opportunist, even under EDITION_2014", () => { + // opportunist has no 2024 counterpart at all, so it's the one key here + // that isolates the subclass gate cleanly (shadowArts/shadowStep/ + // cloakOfShadows share their KEY NAME with the 2024 rows, so a same-slug + // mismatch on those is already covered by the edition-gate test above). + const warriorOfShadow2014 = keys( + deriveActions("monk", "monk-warrior-of-shadow", 20, [pool("ki", 5)], true, "EDITION_2014"), + ); + expect(warriorOfShadow2014).not.toContain("opportunist"); + }); +}); + describe("Warrior of the Elements — Elemental Attunement / Elemental Burst catalog rows (#1315)", () => { const ELEMENTS = "Warrior of the Elements"; @@ -1430,24 +1502,32 @@ describe("subclass gate resolves via slug — FK preferred, exact name as fallba // 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. #1501 widens the - // value shape to carry an optional `edition` (defaulting to EDITION_2024, - // `at()`'s own default) — "monk-way-of-the-open-hand" is the first monk - // slug whose rows are EDITION_2014-only, so its entry must override it. - const MONK_SUBCLASS_GRANT_KEYS: Record, { keys: string[]; edition?: "EDITION_2014" | "EDITION_2024" }> = { - "monk-warrior-of-shadow": { keys: ["shadowStep", "shadowArts", "cloakOfShadows"] }, - "monk-warrior-of-the-elements": { keys: ["elementalBurst"] }, - "monk-warrior-of-the-open-hand": { keys: ["wholenessOfBody", "fleetStep"] }, - "monk-warrior-of-mercy": { keys: ["handOfHealing", "handOfHealingFlurry"] }, - "monk-way-of-the-open-hand": { keys: ["wholenessOfBodyAction", "tranquility"], edition: "EDITION_2014" }, + // 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. + const MONK_SUBCLASS_GRANT_KEYS: Record< + Extract, + { edition: "EDITION_2014" | "EDITION_2024"; keys: string[] } + > = { + "monk-warrior-of-shadow": { edition: "EDITION_2024", keys: ["shadowStep", "shadowArts", "cloakOfShadows"] }, + "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-open-hand": { edition: "EDITION_2014", keys: ["wholenessOfBodyAction", "tranquility"] }, + "monk-way-of-shadow": { edition: "EDITION_2014", keys: ["shadowArts", "shadowStep", "cloakOfShadows", "opportunist"] }, }; it("every subclass-gated row is reachable from its accepted name (#1339, retargeted #1277)", () => { - for (const [slug, { keys: expectedKeys, edition }] of Object.entries(MONK_SUBCLASS_GRANT_KEYS) as [ + for (const [slug, { edition, keys: expectedKeys }] of Object.entries(MONK_SUBCLASS_GRANT_KEYS) as [ SubclassSlug, - { keys: string[]; edition?: "EDITION_2014" | "EDITION_2024" }, + { edition: "EDITION_2014" | "EDITION_2024"; keys: string[] }, ][]) { const name = SUBCLASS_IDENTITY[slug].nameKey; - const granted = keys(at("monk", name, 20, [pool("focus", 5), pool("wholenessOfBody", 5)], true, edition ?? "EDITION_2024")); + const granted = keys( + at("monk", name, 20, [pool("focus", 5), pool("wholenessOfBody", 5), pool("ki", 5)], true, edition), + ); for (const key of expectedKeys) { expect(granted).toContain(key); } diff --git a/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts b/backend/src/lib/classes/__tests__/class-subclasses.fixture.ts index 6c0afe1d..46ad319c 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", "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", "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__/feature-edition.test.ts b/backend/src/lib/classes/__tests__/feature-edition.test.ts index 062234c3..a207da02 100644 --- a/backend/src/lib/classes/__tests__/feature-edition.test.ts +++ b/backend/src/lib/classes/__tests__/feature-edition.test.ts @@ -651,9 +651,9 @@ 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 12 new triples (#1500 base class + #1501 Open Hand's new context - // — the two remaining Warrior-of-* subclasses stay an untouched - // transport-only twin, #1502-#1503): of the base class's 12 + // 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 @@ -669,17 +669,18 @@ const EXPECTED_EDITION_TAGGED_FEATURES = [ // 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/warrior of the elements/warrior of mercy — collectTaggedFeatureKeys - // combines classRows, always ALL of them, with each context's own - // subclassRows) — 2 names x 6 contexts = 12. Way of the Open Hand's OWN - // four feature names are NOT tagged here — #1501 forked it into a - // SEPARATE 2014-only subclass rather than a same-slug fork, so - // loadDbFeatureRows("monk", "way of the open hand") 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 own four names are no - // longer tagged either (they used to be, pre-#1501, when one slug held - // both editions' text) — that slug's rows are now EDITION_2024-only too. + // 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", "undefined", "Martial Arts"], ["monk", "undefined", "Stunning Strike"], ["monk", "warrior of the open hand", "Martial Arts"], @@ -688,6 +689,8 @@ const EXPECTED_EDITION_TAGGED_FEATURES = [ ["monk", "way of the open hand", "Stunning Strike"], ["monk", "warrior of shadow", "Martial Arts"], ["monk", "warrior of shadow", "Stunning Strike"], + ["monk", "way of shadow", "Martial Arts"], + ["monk", "way of shadow", "Stunning Strike"], ["monk", "warrior of the elements", "Martial Arts"], ["monk", "warrior of the elements", "Stunning Strike"], ["monk", "warrior of mercy", "Martial Arts"], 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 f9df49b7..e2c68f16 100644 --- a/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts +++ b/backend/src/lib/classes/__tests__/test-feature-rows.fixture.ts @@ -2664,7 +2664,7 @@ export const WAY_OF_THE_OPEN_HAND_ROWS: ClassFeatureRow[] = [ }, ]; -export const WARRIOR_OF_SHADOW_ROWS: ClassFeatureRow[] = (["EDITION_2014", "EDITION_2024"] as const).flatMap((edition) => [ +export const WARRIOR_OF_SHADOW_ROWS: ClassFeatureRow[] = (["EDITION_2024"] as const).flatMap((edition) => [ { name: "Shadow Arts", level: 3, @@ -2695,6 +2695,39 @@ export const WARRIOR_OF_SHADOW_ROWS: ClassFeatureRow[] = (["EDITION_2014", "EDIT }, ]); +// PHB'14 pp.79-80 (not in SRD 5.1, #1502) — a materially different fork, not a +// retab: see monk-features.ts's WAY_OF_SHADOW_RAW header for what diverges. +export const WAY_OF_SHADOW_ROWS: ClassFeatureRow[] = (["EDITION_2014"] as const).flatMap((edition) => [ + { + name: "Shadow Arts", + level: 3, + edition, + description: + "Starting when you choose this tradition at 3rd level, you can use your ki to duplicate the effects of certain spells. As an action, you can spend 2 ki points to cast darkness, darkvision, pass without trace, or silence, without providing material components. Additionally, you gain the minor illusion cantrip if you don't already know it (PHB'14 pp.79-80 — not in SRD 5.1).", + }, + { + name: "Shadow Step", + level: 6, + edition, + description: + "At 6th level, you gain the ability to step from one shadow to another. When you are in dim light or darkness, as a bonus action you can teleport up to 60 feet to an unoccupied space you can see that is also in dim light or darkness. You then have advantage on the first melee attack you make before the end of the current turn (PHB'14 p.80 — not in SRD 5.1).", + }, + { + name: "Cloak of Shadows", + level: 11, + edition, + description: + "By 11th level, you have learned to become one with the shadows. When you are in an area of dim light or darkness, you can use your action to become invisible. You remain invisible until you make an attack, cast a spell, or are in an area of bright light (PHB'14 p.80 — not in SRD 5.1).", + }, + { + name: "Opportunist", + level: 17, + edition, + description: + "Beginning at 17th level, you can exploit a creature's momentary distraction when it is hit by an attack. When a creature within 5 feet of you is hit by an attack made by a creature other than you, you can use your reaction to make a melee attack against that creature (PHB'14 p.80 — not in SRD 5.1).", + }, +]); + export const WARRIOR_OF_MERCY_ROWS: ClassFeatureRow[] = (["EDITION_2014", "EDITION_2024"] as const).flatMap((edition) => [ { name: "Implements of Mercy", @@ -2809,6 +2842,7 @@ export const LITERAL_SUBCLASS_ROWS: Record = { "warrior of the open hand": WARRIOR_OF_THE_OPEN_HAND_ROWS, "way of the open hand": WAY_OF_THE_OPEN_HAND_ROWS, "warrior of shadow": WARRIOR_OF_SHADOW_ROWS, + "way of shadow": WAY_OF_SHADOW_ROWS, "warrior of mercy": WARRIOR_OF_MERCY_ROWS, "warrior of the elements": WARRIOR_OF_THE_ELEMENTS_ROWS, "school of evocation": WIZARD_EVOCATION_ROWS, diff --git a/backend/src/lib/classes/actions.ts b/backend/src/lib/classes/actions.ts index 3aa61e6b..c5e39df1 100644 --- a/backend/src/lib/classes/actions.ts +++ b/backend/src/lib/classes/actions.ts @@ -439,32 +439,40 @@ const DERIVED_ACTIONS: DerivedActionRecord[] = [ edition: "EDITION_2014", }, - // Every row below for Warrior of Shadow / Warrior of the Elements / Warrior - // of Mercy is subclass-gated via grantSubclassSlugs and deliberately left - // UNTAGGED (#1499): SUBCLASS_SLUGS (subclass-slug.ts) still contains no - // 2014 slug for any of those three, so matchesSubclassGate already excludes - // every one of their rows for a 2014 monk — an edition tag would add no - // observable behaviour, pending #1502-#1503. Warrior of the Open Hand is - // DIFFERENT as of #1501: it now has a real 2014 sibling ("Way of the Open - // Hand", its own slug, its own rows below) sharing the SAME action key - // ("wholenessOfBody") — reusing an untagged row here would serve the 2024 - // shape (bonus action, Wis-mod pool) to a 2014 character too, so - // wholenessOfBody/fleetStep below are now tagged EDITION_2024, bound in the - // same commit as the "Warrior of the Open Hand" Subclass row's own retag - // (subclasses.ts). + // Every row below is subclass-gated via grantSubclassSlugs. Warrior of the + // Elements / Warrior of Mercy stay deliberately UNTAGGED (#1499): + // SUBCLASS_SLUGS still contains no 2014 slug for either, so + // matchesSubclassGate already excludes every one of their rows for a 2014 + // monk — an edition tag would add no observable behaviour, pending #1503. + // Warrior of Shadow (#1502) and Warrior of the Open Hand (#1501) are BOTH + // now explicitly tagged EDITION_2024, for the same underlying reason: + // SUBCLASS_SLUGS gained a real 2014 sibling for each (monk-way-of-shadow, + // monk-way-of-the-open-hand), so slug-gating alone already isolates the + // 2024 rows below, but the edition tag is now load-bearing defence-in-depth + // rather than a no-op. The two siblings diverge on KEY REUSE, not just + // content: Way of Shadow's rows reuse the SAME keys as Warrior of Shadow + // (shadowArts/shadowStep/cloakOfShadows — see the 2014 block further down, + // disambiguated by edition + grantSubclassSlugs alone), while Way of the + // Open Hand's Wholeness of Body needs its OWN key + // ("wholenessOfBodyAction", not this block's "wholenessOfBody") because its + // shape differs enough that reusing the key would blur two client-side + // resolvers — see that row's own comment. Both retags are bound in the + // same commit as their Subclass row's own edition tag (subclasses.ts). // Warrior of Shadow reminder action (2024 rewrite, #1246) — no resourceKey, no // server effect; reminder is the deliverable. Improved Shadow Step (L11) // upgrades the SAME bonus action (ignore the dim/dark destination requirement // for 1 focus) rather than adding a competing catalog row — mirrors how // Heightened Focus upgrades patientDefenseFocus/stepOfTheWindFocus in place. - // Opportunist (2014 L17 reaction) is retired — replaced by Cloak of Shadows - // (shadow-arts.ts activateCloakOfShadows), a real resourceKey-gated cast, not - // a catalog reminder. + // Opportunist (2014 L17 reaction) is retired for THIS (2024) subclass — + // replaced by Cloak of Shadows (shadow-arts.ts activateCloakOfShadows), a + // real resourceKey-gated cast, not a catalog reminder. The 2014 Way of + // Shadow fork below reinstates Opportunist under its own slug (#1502). { key: "shadowStep", name: "Shadow Step", cost: "bonusAction", grantClass: "monk", + edition: "EDITION_2024", grantSubclassSlugs: ["monk-warrior-of-shadow"], grantLevel: 6, reminder: (level) => @@ -487,6 +495,7 @@ const DERIVED_ACTIONS: DerivedActionRecord[] = [ name: "Shadow Arts (Darkness)", cost: "action", grantClass: "monk", + edition: "EDITION_2024", grantSubclassSlugs: ["monk-warrior-of-shadow"], grantLevel: 3, resourceKey: "focus", @@ -498,6 +507,7 @@ const DERIVED_ACTIONS: DerivedActionRecord[] = [ name: "Cloak of Shadows", cost: "action", grantClass: "monk", + edition: "EDITION_2024", grantSubclassSlugs: ["monk-warrior-of-shadow"], grantLevel: 17, resourceKey: "focus", @@ -505,6 +515,60 @@ const DERIVED_ACTIONS: DerivedActionRecord[] = [ reminder: "Magic action, entirely within dim light or darkness: spend 3 focus to become invisible and move through creatures/objects as difficult terrain for 1 minute (or until incapacitated, or you end your turn in bright light). Flurry of Blows costs no focus while it lasts.", }, + // 2014 Way of Shadow (PHB'14 pp.79-80 — not in SRD 5.1, #1502): a + // materially different fork from the 2024 rewrite above, not a retab — + // Shadow Arts is a flat 4-spell 2-ki menu (Darkness/Darkvision/Pass without + // Trace/Silence, the exact per-spell menu resolved from the GrantedAbility + // catalog by shadow-arts.ts, not this row), Shadow Step grants no free + // unarmed strike, Cloak of Shadows moves to L11 with NO resource cost (no + // resourceKey at all — action only), and Opportunist returns at L17 as a + // pure reminder reaction (no 2024 equivalent). Same KEY NAMES as the 2024 + // rows above (shadowArts/shadowStep/cloakOfShadows) — the edition tag plus + // grantSubclassSlugs disambiguates, never a second vocabulary; opportunist + // is the one 2014-only key with no 2024 counterpart. + { + key: "shadowArts", + name: "Shadow Arts", + cost: "action", + grantClass: "monk", + edition: "EDITION_2014", + grantSubclassSlugs: ["monk-way-of-shadow"], + grantLevel: 3, + resourceKey: "ki", + resourceAmount: 2, + reminder: "Spend 2 ki to cast darkness, darkvision, pass without trace, or silence, without material components (PHB'14 pp.79-80 — not in SRD 5.1).", + }, + { + key: "shadowStep", + name: "Shadow Step", + cost: "bonusAction", + grantClass: "monk", + edition: "EDITION_2014", + grantSubclassSlugs: ["monk-way-of-shadow"], + grantLevel: 6, + reminder: "While in dim light or darkness, teleport as a bonus action up to 60 ft to an unoccupied space you can see that is also in dim light or darkness; you then have advantage on the first melee attack you make before the end of the turn.", + }, + { + key: "cloakOfShadows", + name: "Cloak of Shadows", + cost: "action", + grantClass: "monk", + edition: "EDITION_2014", + grantSubclassSlugs: ["monk-way-of-shadow"], + grantLevel: 11, + reminder: "While in dim light or darkness, use your action to become invisible; you remain invisible until you make an attack, cast a spell, or are in an area of bright light. No ki cost, no duration cap.", + }, + { + key: "opportunist", + name: "Opportunist", + cost: "reaction", + grantClass: "monk", + edition: "EDITION_2014", + grantSubclassSlugs: ["monk-way-of-shadow"], + grantLevel: 17, + reminder: "When a creature within 5 ft of you is hit by an attack made by a creature other than you, use your reaction to make a melee attack against that creature.", + }, + // Warrior of the Elements (PHB'24 p.90 — not in SRD 5.2, which ships only // Warrior of the Open Hand for monk). Elemental Attunement retired from // this table (#1686) — row-driven now (monk.ts's AuthoredFeature entry, diff --git a/backend/src/lib/classes/monk.ts b/backend/src/lib/classes/monk.ts index 67de8fb9..134cc652 100644 --- a/backend/src/lib/classes/monk.ts +++ b/backend/src/lib/classes/monk.ts @@ -142,6 +142,17 @@ export const monk: ClassDefinition = { // than deriveExtras booleans — one shared level-gate registry for every // monk action instead of a second copy here (#1315). }, + "way of shadow": { + slug: "monk-way-of-shadow", + grantLevel: 3, + // 2014 fork of Warrior of Shadow (PHB'14 pp.79-80 — not in SRD 5.1, + // #1502): Shadow Arts (L3, 2 ki, four-spell menu) / Shadow Step (L6, no + // free unarmed strike) / Cloak of Shadows (L11, no resource cost) / + // Opportunist (L17) gate live as DERIVED_ACTIONS rows (actions.ts), + // tagged EDITION_2014 and gated on THIS subclass's own slug — same + // discipline as Warrior of Shadow above, never a second registry. No + // dedicated pool of its own: every cost draws on the base Ki pool. + }, "warrior of the elements": { slug: "monk-warrior-of-the-elements", grantLevel: 3, diff --git a/backend/src/lib/classes/shadow-arts.ts b/backend/src/lib/classes/shadow-arts.ts index 5491bb1d..2e151590 100644 --- a/backend/src/lib/classes/shadow-arts.ts +++ b/backend/src/lib/classes/shadow-arts.ts @@ -1,22 +1,36 @@ /** - * Warrior of Shadow cast handlers (#1246, 2024 rewrite of the former #441 Way of - * Shadow) — two focus-fuelled abilities live here: + * Warrior of Shadow / Way of Shadow cast handlers — two focus/ki-fuelled + * abilities live here, both edition-aware since #1502: * - * castShadowArt — the L3 Shadow Arts feature's 1-focus Darkness cast. - * A GrantedAbility row (source "shadowArts") read from - * the catalog, routed through castAbilityInTx like a - * Channel Divinity cast. - * activateCloakOfShadows — the L17 Cloak of Shadows feature: spend 3 focus, - * self-apply the invisible condition. No catalog row - * (a single fixed feature, not a "choose one" menu), - * so its cost/effect are hardcoded here. + * castShadowArt — the L3 Shadow Arts feature's cast. A + * GrantedAbility row (source "shadowArts") read + * from the catalog, routed through castAbilityInTx + * like a Channel Divinity cast. 2024 (Warrior of + * Shadow, PHB'24 p.91, #1246): exactly one cast, + * 1 focus, Darkness, always concentrates. 2014 + * (Way of Shadow, PHB'14 pp.79-80 — not in SRD + * 5.1, #1502): a 4-spell 2-ki menu (Darkness/ + * Darkvision/Pass without Trace/Silence); only + * Darkness/Pass without Trace/Silence concentrate + * (Darkvision does not) — see + * shadowArtEffectSpec's name-keyed concentrates. + * Every field (cost pool/amount, concentration) + * is read from the catalog row itself, so this + * function needs no `edition` branch of its own. + * activateCloakOfShadows — the Cloak of Shadows feature: self-apply the + * invisible condition. No catalog row (a single + * fixed feature, not a "choose one" menu), so its + * cost/effect are hardcoded here — edition- + * branched, since the 2014 and 2024 shapes + * genuinely diverge (2014, L11: action only, no + * ki cost, no duration cap beyond "until you + * attack, cast a spell, or are in bright light"; + * 2024, L17: 3 focus, 1 minute, frees Flurry of + * Blows while it lasts). * - * The 2014 model (flat 2-focus cost, a 4-spell menu of Darkness/Darkvision/Pass - * without Trace/Silence, and a per-name concentration set) is retired: 2024 - * Shadow Arts has exactly one cast (Darkness, always concentrates) plus passive - * Minor Illusion + Darkvision grants that carry no persisted state (Minor - * Illusion is a subclass-granted spell, seed/subclass-granted-spells.ts; - * Darkvision is flavor text — this app tracks no senses). + * Both level gates (3/17 for 2024, 3/6/11/17 for 2014) live as DERIVED_ACTIONS + * rows (actions.ts) — this file never hardcodes a level, only reads whether + * the entry-scoped action key is present. */ import type { CastShadowArtOperation, ShadowArtOperation } from "@character-sheet/contracts"; @@ -41,14 +55,19 @@ export class InvalidShadowArtOperationError extends Error {} // Prefix stamped on a Shadow Art's concentration entryId so its id space never overlaps a spellbook Spell.id. export const SHADOW_ART_CONCENTRATION_PREFIX = "shadow-art:"; -// Cloak of Shadows carries no GrantedAbility catalog row (a single fixed L17 +// Cloak of Shadows carries no GrantedAbility catalog row (a single fixed // feature, not a "choose one" menu like Shadow Arts) — its cost/effect/entryId -// are fixed constants rather than read from the DB. +// are fixed constants rather than read from the DB. 2024's cost (PHB'24 p.91); +// 2014 costs nothing (PHB'14 p.80, "no ki cost, no duration cap") — expressed +// via AbilityCost's existing `{kind:"none"}` variant, not a fake 0-base pool +// spend, so payAbilityCostInTx's `case "none"` short-circuit (no +// applySpendResourceInTx call at all) does the right thing for free. const CLOAK_OF_SHADOWS_NAME = "Cloak of Shadows"; const CLOAK_OF_SHADOWS_ENTRY_ID = "cloak-of-shadows"; // Narrowed to the "pool" variant (not the bare AbilityCost union) so `.base` is // accessible below without a runtime kind check — this constant is always a pool cost. -const CLOAK_OF_SHADOWS_COST: Extract = { kind: "pool", key: "focus", base: 3 }; +const CLOAK_OF_SHADOWS_2024_COST: Extract = { kind: "pool", key: "focus", base: 3 }; +const CLOAK_OF_SHADOWS_2014_COST: Extract = { kind: "none" }; const CLOAK_OF_SHADOWS_EFFECT: EffectSpec = { effectType: "utility", damageType: null, @@ -59,7 +78,7 @@ const CLOAK_OF_SHADOWS_EFFECT: EffectSpec = { concentration: false, }; -// Catalog columns needed to build the Shadow Arts Darkness cast's flat EffectSpec. +// Catalog columns needed to build a Shadow Art cast's flat EffectSpec. export interface ShadowArtEffectRow { name: string; effectKind?: string | null; @@ -67,17 +86,25 @@ export interface ShadowArtEffectRow { buffModifier?: number | null; } +// The one 2014 Shadow Art that does NOT concentrate — Darkvision (PHB'14: "Duration: +// 8 hours", no "Concentration" prefix), unlike Darkness/Pass without Trace/Silence +// (all "Duration: Concentration, up to ..."). 2024's sole row ("Shadow Arts: +// Darkness") is untouched by this set. Name-keyed rather than edition-keyed: the +// same predicate is correct for both editions without this function ever taking +// an `edition` parameter, since only ONE name in the whole catalog is exempt. +const SHADOW_ARTS_NO_CONCENTRATION = new Set(["Shadow Arts: Darkvision"]); + /** - * Build the Darkness cast's EffectSpec via the shared catalogEffectSpec builder: - * flat (scaling.mode "none"), always concentrates — 2024 Shadow Arts has exactly - * one cast and it's Darkness (SRD 5.2). Kept on the shared row→spec mapping + * Build a Shadow Art cast's EffectSpec via the shared catalogEffectSpec builder: + * flat (scaling.mode "none"); concentrates unless the row's name is in + * SHADOW_ARTS_NO_CONCENTRATION above. Kept on the shared row→spec mapping * (lib/combat/effects.ts, #817) rather than inlined, since the same builder also * serves Channel Divinity. */ export function shadowArtEffectSpec(row: ShadowArtEffectRow): EffectSpec { return catalogEffectSpec(row, { scaling: { mode: "none" }, - concentrates: () => true, + concentrates: (name) => !SHADOW_ARTS_NO_CONCENTRATION.has(name), }); } @@ -133,6 +160,13 @@ async function applyCastShadowArt( // log the undoable spellcasting event (restores concentratingOn on revert). // The resources cast record restores nothing (focus refunded by the pool // payer's spendResource event, concentration by the event above). + // + // The spend field name is keyed off `cost.key` (the catalog row's own + // costPoolKey, "ki" or "focus"), not hardcoded — a 2014 Way of Shadow cast + // spends ki, and a hardcoded `focusSpent` would mislabel that spend in the + // persisted CharacterEvent.data column (the human-readable `resourceSummary` + // already says "Ki Points" via the shared spendResource summary; this is + // the structured half). await emitFocusCastEvents(tx, { characterId, batchId, @@ -144,21 +178,24 @@ async function applyCastShadowArt( concentrationName: catalog.name, concentrationData: { shadowArtId: catalog.id, shadowArtName: catalog.name }, resourceSummary: outcome.summary, - resourceData: { shadowArtId: catalog.id, focusSpent: cost.base }, + resourceData: { shadowArtId: catalog.id, [`${cost.key}Spent`]: cost.base }, }); } -// Pay Cloak of Shadows' fixed 3-focus cost, self-apply invisible, and log the -// combined result. No concentration (it ends manually on attack/cast/bright -// light, like the 2014 version), so this skips emitFocusCastEvents' concentration -// branch and instead mirrors applyChannelDivinityOperations' invisible-kind tail. +// Pay Cloak of Shadows' cost (3 focus in 2024, nothing in 2014 — see the +// constants above), self-apply invisible, and log the combined result. No +// concentration (it ends manually on attack/cast/bright light, matching the +// 2014 text exactly and mirroring the 2024 rewrite), so this skips +// emitFocusCastEvents' concentration branch and instead mirrors +// applyChannelDivinityOperations' invisible-kind tail. async function applyActivateCloakOfShadows( tx: Prisma.TransactionClient, characterId: string, batchId: string, sessionId: string | null, - row: { spellcasting: Prisma.JsonValue }, + row: { spellcasting: Prisma.JsonValue; rulesEdition: RulesEdition }, ): Promise { + const cost = editionOf(row) === "EDITION_2014" ? CLOAK_OF_SHADOWS_2014_COST : CLOAK_OF_SHADOWS_2024_COST; const spellState = normalizeSpellcastingMutable(row.spellcasting); const costCtx: PayCostContext = { tx, characterId, batchId, sessionId }; const outcome = await castAbilityInTx( @@ -166,9 +203,9 @@ async function applyActivateCloakOfShadows( { name: CLOAK_OF_SHADOWS_NAME, entryId: CLOAK_OF_SHADOWS_ENTRY_ID, - cost: CLOAK_OF_SHADOWS_COST, + cost, effect: CLOAK_OF_SHADOWS_EFFECT, - requested: CLOAK_OF_SHADOWS_COST.base, + requested: cost.kind === "pool" ? cost.base : undefined, roll: 0, eventType: "castShadowArt", concentrates: false, @@ -182,7 +219,7 @@ async function applyActivateCloakOfShadows( category: "resources", type: "castShadowArt", summary: outcome.summary, - data: { focusSpent: CLOAK_OF_SHADOWS_COST.base }, + data: { focusSpent: cost.kind === "pool" ? cost.base : 0 }, batchId, sessionId, }); @@ -216,12 +253,18 @@ export async function applyShadowArtsOperations( // would wrongly reject every cast; if that's ever needed, pass the real // pools instead of widening this comment. const level = levelForExperience(row.experiencePoints); - const actions = deriveEntryScopedActions(row.classEntries, level, [], true, editionOf(row)); + const edition = editionOf(row); + const actions = deriveEntryScopedActions(row.classEntries, level, [], true, edition); + // Error text names the edition-correct subclass and level — 2014's Way + // of Shadow gates Cloak of Shadows at L11, not 2024's L17 (PHB'14 p.80 + // vs PHB'24 p.91); Shadow Arts gates at L3 in both. + const subclassLabel = edition === "EDITION_2014" ? "Way of Shadow" : "Warrior of Shadow"; if (op.type === "activateCloakOfShadows") { if (!actions.some((a) => a.key === "cloakOfShadows")) { + const gateLevel = edition === "EDITION_2014" ? 11 : 17; throw new InvalidShadowArtOperationError( - "Only a Warrior of Shadow monk (level 17+) can use Cloak of Shadows", + `Only a ${subclassLabel} monk (level ${gateLevel}+) can use Cloak of Shadows`, ); } await applyActivateCloakOfShadows(tx, characterId, batchId, sessionId, row); @@ -230,7 +273,7 @@ export async function applyShadowArtsOperations( if (!actions.some((a) => a.key === "shadowArts")) { throw new InvalidShadowArtOperationError( - "Only a Warrior of Shadow monk (level 3+) can cast Shadow Arts spells", + `Only a ${subclassLabel} monk (level 3+) can cast Shadow Arts spells`, ); } await applyCastShadowArt(tx, characterId, op, batchId, sessionId, row); diff --git a/backend/src/lib/classes/subclass-slug.ts b/backend/src/lib/classes/subclass-slug.ts index 2261af32..819c1120 100644 --- a/backend/src/lib/classes/subclass-slug.ts +++ b/backend/src/lib/classes/subclass-slug.ts @@ -46,6 +46,7 @@ export const SUBCLASS_SLUGS = [ "monk-warrior-of-shadow", "monk-warrior-of-the-elements", "monk-warrior-of-the-open-hand", + "monk-way-of-shadow", "monk-way-of-the-open-hand", "paladin-oath-of-devotion", "paladin-oath-of-the-ancients", @@ -64,13 +65,17 @@ export const SUBCLASS_SLUGS = [ "wizard-school-of-abjuration", "wizard-school-of-evocation", "wizard-school-of-illusion", -] as const; // 33 members — the seed's row count (#1277 F1) and the +] as const; // 34 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 // lib/classes/wizard.ts subclass registration exists or is needed, since its // mechanics ride the F1-F5 engine's seed-row vocabulary, not a -// SubclassDefinition. "monk-way-of-the-open-hand" (#1501) is the first slug +// SubclassDefinition. monk-way-of-shadow (#1502) is the 2014 Way of Shadow +// fork of monk-warrior-of-shadow — a DISTINCT slug, not a retag, because both +// lineages coexist per campaign (epic #1281): a 2014 Way of Shadow monk must +// never inherit 2024 Warrior of Shadow mechanics or vice versa (#1339). +// "monk-way-of-the-open-hand" (#1501) is the sibling fork: the first slug // 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. @@ -105,6 +110,7 @@ export const SUBCLASS_IDENTITY: Record = { "monk-warrior-of-shadow": { classKey: "monk", nameKey: "warrior of shadow" }, "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-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/routes/character/__tests__/shadow-arts-cast.test.ts b/backend/src/routes/character/__tests__/shadow-arts-cast.test.ts index 43d8f838..743816c0 100644 --- a/backend/src/routes/character/__tests__/shadow-arts-cast.test.ts +++ b/backend/src/routes/character/__tests__/shadow-arts-cast.test.ts @@ -123,7 +123,11 @@ describe("Shadow Arts cast endpoint", () => { { gateLevel: 3, castingAbility: "wisdom" }, ); - darknessId = (await prisma.grantedAbility.findFirst({ where: { name: "Shadow Arts: Darkness" } }))!.id; + // This suite's fixtures default to EDITION_2024 (rulesEdition unset) — the + // real "Shadow Arts: Darkness" name now exists once per edition (#1502), + // so the lookup must pin the 2024 row explicitly or a bare findFirst is + // nondeterministic between the two. + darknessId = (await prisma.grantedAbility.findFirst({ where: { name: "Shadow Arts: Darkness", edition: "EDITION_2024" } }))!.id; }); afterAll(async () => { @@ -359,6 +363,189 @@ describe("Shadow Arts cast endpoint", () => { }); }); +// #1502: 2014 Way of Shadow (PHB'14 pp.79-80 — not in SRD 5.1) — its OWN +// edition-tagged fixture (rulesEdition: EDITION_2014), proving the real cast +// mechanics: the four-spell 2-ki menu, per-spell concentration (Darkness/Pass +// without Trace/Silence concentrate, Darkvision does not), Shadow Step at L6, +// Cloak of Shadows at L11 with no ki cost, and Opportunist at L17 (reminder +// only, no cast endpoint). +describe("2014 Way of Shadow — real edition-tagged cast mechanics (#1502)", () => { + const WAY_ID = "test-way-of-shadow-monk-1"; + const WAY_CLASS_NAME = "Way of Shadow Test Monk"; + let wayClassId: string; + const wayArtId: Record = {}; + + beforeAll(async () => { + const cls = await prisma.characterClass.upsert({ + where: { name: WAY_CLASS_NAME }, + create: { name: WAY_CLASS_NAME, hitDie: "d8", savingThrows: ["strength", "dexterity"], skillChoiceCount: 2, skillChoices: ["acrobatics", "stealth"], isSpellcaster: false }, + update: {}, + }); + wayClassId = cls.id; + + // Way of Shadow grants Minor Illusion at L3 as data (#898, #1502) — this + // is what gives a pure (non-caster) Way of Shadow monk a serialized + // spellcasting view at all, mirroring the 2024 fixture's own setup above. + const way = await upsertEditionRow( + prisma.subclass, + { classId: wayClassId, name: "Way of Shadow", edition: "EDITION_2014" }, + // Distinct from the real seeded "monk-way-of-shadow" (#1277) — this + // test's Monk class is its own throwaway row. + { classId: wayClassId, name: "Way of Shadow", description: "Test subclass", slug: "monk-way-of-shadow-cast-test", edition: "EDITION_2014" }, + {}, + ); + const minorIllusion = await prisma.spell.findFirst({ where: { name: "Minor Illusion" }, select: { id: true } }); + if (!minorIllusion) throw new Error("Minor Illusion not seeded — run `prisma db seed` before tests"); + await upsertEditionRow( + prisma.subclassGrantedSpell, + { subclassId: way.id, spellId: minorIllusion.id, edition: "EDITION_2014" }, + { subclassId: way.id, spellId: minorIllusion.id, gateLevel: 3, castingAbility: "wisdom", edition: "EDITION_2014" }, + { gateLevel: 3, castingAbility: "wisdom" }, + ); + + for (const name of ["Shadow Arts: Darkness", "Shadow Arts: Darkvision", "Shadow Arts: Pass without Trace", "Shadow Arts: Silence"]) { + const row = await prisma.grantedAbility.findFirst({ where: { name, edition: "EDITION_2014" } }); + if (!row) throw new Error(`${name} (EDITION_2014) not seeded — run \`prisma db seed\` before tests`); + wayArtId[name] = row.id; + } + }); + + afterAll(async () => { + await prisma.characterClass.deleteMany({ where: { name: WAY_CLASS_NAME } }); + }); + + beforeEach(async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); + }); + + afterEach(async () => { + await prisma.character.deleteMany({ where: { id: WAY_ID } }); + }); + + async function createWayOfShadowMonk(experiencePoints: number) { + const sub = await prisma.subclass.findFirst({ + where: { classId: wayClassId, name: { equals: "Way of Shadow", mode: "insensitive" } }, + select: { id: true }, + }); + await prisma.character.create({ + data: { + ...FIXTURE_BASE, + id: WAY_ID, + experiencePoints, + ownerId: OWNER_ID, + rulesEdition: "EDITION_2014", + resources: Prisma.JsonNull, + classEntries: { + create: [{ name: "monk", subclass: "Way of Shadow", subclassId: sub?.id, classId: wayClassId, position: 0 }], + }, + }, + }); + } + + const wayUrl = `/api/characters/${WAY_ID}/abilities/shadow-arts/transactions`; + const wayActivityUrl = `/api/characters/${WAY_ID}/activity?category=resources`; + async function wayCast(operations: unknown[]) { + return agent().post(wayUrl).send({ operations }); + } + async function wayActivity(): Promise { + const res = await agent().get(wayActivityUrl); + return res.body as ActivityEvent[]; + } + + it("shadowArts (L3) spends exactly 2 ki, regardless of which of the four spells is cast", async () => { + await createWayOfShadowMonk(XP_L3); + const res = await wayCast([{ type: "castShadowArt", shadowArtId: wayArtId["Shadow Arts: Silence"] }]); + expect(res.status).toBe(200); + const ki = res.body.resources.pools.find((p: { key: string }) => p.key === "ki"); + expect(ki.used).toBe(2); + }); + + // The structured audit `data` column must name the resource actually spent + // (ki, not focus) — the summary text already says "Ki Points" via the + // shared spendResource path; this pins the sibling field shadow-arts.ts's + // own castShadowArt event carries. + it("records the audit event's spend under kiSpent, never focusSpent (edition-correct resource key)", async () => { + await createWayOfShadowMonk(XP_L3); + const res = await wayCast([{ type: "castShadowArt", shadowArtId: wayArtId["Shadow Arts: Silence"] }]); + expect(res.status).toBe(200); + const events = await wayActivity(); + const castEvent = events.find((e) => e.type === "castShadowArt"); + expect(castEvent?.data).toMatchObject({ kiSpent: 2 }); + expect(castEvent?.data).not.toHaveProperty("focusSpent"); + }); + + it("concentrates on Darkness, Pass without Trace, and Silence, but NOT Darkvision (PHB'14: only Darkvision's duration is non-concentration)", async () => { + await createWayOfShadowMonk(XP_L3); + const darkvision = await wayCast([{ type: "castShadowArt", shadowArtId: wayArtId["Shadow Arts: Darkvision"] }]); + expect(darkvision.status).toBe(200); + expect(darkvision.body.spellcasting.concentratingOn).toBeNull(); + await prisma.character.deleteMany({ where: { id: WAY_ID } }); + + await createWayOfShadowMonk(XP_L3); + const passWithoutTrace = await wayCast([{ type: "castShadowArt", shadowArtId: wayArtId["Shadow Arts: Pass without Trace"] }]); + expect(passWithoutTrace.status).toBe(200); + expect(passWithoutTrace.body.spellcasting.concentratingOn).toMatchObject({ spellName: "Shadow Arts: Pass without Trace" }); + }); + + it("gets shadowStep at L6 and shadowArts at L3, but not cloakOfShadows or opportunist yet", async () => { + await createWayOfShadowMonk(XP_L3); + const l3 = await agent().get(`/api/characters/${WAY_ID}`); + const l3Keys = (l3.body.availableActions as { key: string }[]).map((a) => a.key); + expect(l3Keys).toContain("shadowArts"); + expect(l3Keys).not.toContain("shadowStep"); + expect(l3Keys).not.toContain("cloakOfShadows"); + expect(l3Keys).not.toContain("opportunist"); + }); + + it("activateCloakOfShadows (L11) costs no ki at all", async () => { + // XP for level 11 (experience.ts's XP table: level 11 = 85000 XP). + await createWayOfShadowMonk(85000); + const l11 = await agent().get(`/api/characters/${WAY_ID}`); + expect((l11.body.availableActions as { key: string }[]).some((a) => a.key === "cloakOfShadows")).toBe(true); + expect((l11.body.availableActions as { key: string }[]).some((a) => a.key === "shadowStep")).toBe(true); + + const res = await wayCast([{ type: "activateCloakOfShadows" }]); + expect(res.status).toBe(200); + const ki = res.body.resources.pools.find((p: { key: string }) => p.key === "ki"); + expect(ki.used).toBe(0); + expect(res.body.conditions.active).toContainEqual( + expect.objectContaining({ key: "invisible", source: "Cloak of Shadows" }), + ); + + const events = await wayActivity(); + expect(events.some((e) => e.type === "castShadowArt" && e.data?.focusSpent === 0)).toBe(true); + }); + + it("rejects activateCloakOfShadows below L11 (not L17 — the 2024 gate)", async () => { + await createWayOfShadowMonk(XP_L3); + const res = await wayCast([{ type: "activateCloakOfShadows" }]); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Way of Shadow/i); + expect(res.body.error).toMatch(/level 11/i); + }); + + it("opportunist (L17) surfaces on the sheet as a reminder-only reaction — no cast endpoint", async () => { + await createWayOfShadowMonk(XP_L17); + const l17 = await agent().get(`/api/characters/${WAY_ID}`); + const opportunist = (l17.body.availableActions as { key: string; cost: string }[]).find((a) => a.key === "opportunist"); + expect(opportunist).toBeDefined(); + expect(opportunist?.cost).toBe("reaction"); + }); + + it("a 2014 Way of Shadow monk's shadowArts picker (edition=EDITION_2014) lists exactly the four 2014 arts, never the 2024 one", async () => { + const res = await agent().get("/api/shadow-arts?edition=EDITION_2014"); + expect(res.status).toBe(200); + const names = (res.body as { name: string }[]).map((a) => a.name).sort(); + expect(names).toEqual([ + "Shadow Arts: Darkness", + "Shadow Arts: Darkvision", + "Shadow Arts: Pass without Trace", + "Shadow Arts: Silence", + ]); + }); +}); + // #1315: availableActions is entry-scoped (mirrors deriveEntryScopedResources, // #1206) — a secondary Warrior of Shadow monk's shadowArts/cloakOfShadows key // off the MONK entry's own level, not the primary entry's class or the @@ -467,7 +654,7 @@ describe("resolveConcentration clamp for multiclass Warrior of Shadow", () => { update: {}, }); mcClassId = cls.id; - mcDarknessId = (await prisma.grantedAbility.findFirst({ where: { name: "Shadow Arts: Darkness" } }))!.id; + mcDarknessId = (await prisma.grantedAbility.findFirst({ where: { name: "Shadow Arts: Darkness", edition: "EDITION_2024" } }))!.id; }); afterAll(async () => { @@ -536,9 +723,9 @@ describe("shadowArtEffectSpec", () => { }); it("still resolves the generic buff shape (shared catalogEffectSpec builder) for a hypothetical buff row", () => { - // No current Shadow Art carries a buff (the 2014 Pass without Trace option - // is retired, #1246) — this pins that the shared row→spec mapping still - // works, since it's reused by Channel Divinity too. + // No real Shadow Art carries an effectKind:"buff" row (every 2014/2024 + // art is a flat utility cast, #1502) — this pins that the shared + // row→spec mapping still works, since it's reused by Channel Divinity too. const spec = shadowArtEffectSpec({ name: "Shadow Arts: Hypothetical Buff", effectKind: "buff", @@ -621,13 +808,14 @@ describe("Shadow Arts source guard", () => { const as2024 = await agent().get("/api/shadow-arts?edition=EDITION_2024"); expect(as2024.status).toBe(200); expect((as2024.body as { id: string }[]).some((a) => a.id === row.id)).toBe(false); - // The NULL-edition seeded Darkness row still reaches both editions. + // The real EDITION_2024-tagged Darkness row (#1502 — no longer shared/null). expect((as2024.body as { name: string }[]).length).toBe(1); const as2014 = await agent().get("/api/shadow-arts?edition=EDITION_2014"); expect(as2014.status).toBe(200); expect((as2014.body as { id: string }[]).some((a) => a.id === row.id)).toBe(true); - expect((as2014.body as { name: string }[]).length).toBe(2); + // The four real EDITION_2014 Shadow Arts rows (#1502) plus this fixture's own. + expect((as2014.body as { name: string }[]).length).toBe(5); } finally { await prisma.grantedAbility.deleteMany({ where: { name: FIXTURE_NAME } }); }