From f802cbd281293f855111a8fb16f3c50533c7720e Mon Sep 17 00:00:00 2001 From: Steffen Andersland Date: Tue, 4 Aug 2026 21:11:21 -0400 Subject: [PATCH 1/2] feat(feats): author the 2014 Feat catalog rows behind the edition tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the 2014 half of the Feat catalog `6491c528` (#1154) replaced. Adds 25 new EDITION_2014 rows (24 recovered verbatim from the pre-#1154 catalog, plus Grappler and Savage Attacker freshly transcribed from SRD 5.1 / PHB'14) and stamps the 32 previously-shared rows EDITION_2024, so every Feat row is now edition-tagged and no NULL row remains — a 2014 character no longer sees PHB'24 rules text for feats it never had. PHB'14 has no Origin/Fighting Style/Epic Boon taxonomy, so every 2014 row is category "general" with no levelPrerequisite; featOfferedForAsiSlot's `?? 4` default is what encodes PHB'14's "earliest ASI is level 4" rule, so the function drops its now-unused `edition` parameter (an edition-invariant rule takes none). Fixes the fixture-lookup drift the plan flagged in advance: species-creation-choices-2024.test.ts's featByName helper resolved by bare name and would have picked a nondeterministic edition now that Savage Attacker/Magic Initiate fork — pinned to EDITION_2024. Closes #1310 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn --- .../seed/__tests__/feat-fork-reseed.test.ts | 6 + .../prisma/seed/__tests__/seed-data.test.ts | 114 ++++++- backend/prisma/seed/feats.ts | 298 +++++++++++++++++- backend/src/lib/leveling/advancement.ts | 7 +- .../catalog-edition-constraints.test.ts | 7 +- .../lib/srd/__tests__/srd-feat-offer.test.ts | 52 ++- backend/src/lib/srd/feats.ts | 24 +- .../feats-asi-level-defaults.test.ts | 15 +- .../routes/catalog/__tests__/feats.test.ts | 79 ++++- .../catalog/__tests__/reference.test.ts | 12 +- backend/src/routes/catalog/feats.ts | 1 - .../__tests__/feats-2014-general-take.test.ts | 124 ++++++++ .../species-creation-choices-2024.test.ts | 6 +- 13 files changed, 659 insertions(+), 86 deletions(-) create mode 100644 backend/src/routes/character/__tests__/feats-2014-general-take.test.ts diff --git a/backend/prisma/seed/__tests__/feat-fork-reseed.test.ts b/backend/prisma/seed/__tests__/feat-fork-reseed.test.ts index b85689d6..2c6199fb 100644 --- a/backend/prisma/seed/__tests__/feat-fork-reseed.test.ts +++ b/backend/prisma/seed/__tests__/feat-fork-reseed.test.ts @@ -6,6 +6,12 @@ // granted-ability-fork-reseed.test.ts's retag scenario (#1229), applied to // Feat/staleCatalogRowsWhere instead of GrantedAbility. // +// The mechanism doesn't branch on `category` — #1310 retags 32 previously- +// shared General/Origin rows the same way (verified live: `prisma db seed` +// run twice against the real catalog leaves 32 EDITION_2014 / 37 EDITION_2024 +// / 0 NULL rows both times), so this fixture (built on `fighting_style`) is +// not duplicated per category. +// // Fixture rule: every row uses a Zzz-prefixed name unique to this file so // staleCatalogRowsWhere's "everything NOT in the seeded list" scope never // touches the real seeded catalog. diff --git a/backend/prisma/seed/__tests__/seed-data.test.ts b/backend/prisma/seed/__tests__/seed-data.test.ts index 45874e9c..6012ec4a 100644 --- a/backend/prisma/seed/__tests__/seed-data.test.ts +++ b/backend/prisma/seed/__tests__/seed-data.test.ts @@ -18,7 +18,7 @@ import type { ClassDefinition } from "@/lib/classes/types.js"; import { warlock } from "@/lib/classes/warlock.js"; import { wizard } from "@/lib/classes/wizard.js"; -import { CLASSES, ITEMS } from "../catalog-data.js"; +import { BACKGROUNDS, CLASSES, ITEMS } from "../catalog-data.js"; import { ACTIONS, TWENTY_FOUR_ONLY_ACTION_KEYS } from "../actions.js"; import { SUBCLASSES } from "../subclasses.js"; import { MANEUVERS } from "../maneuvers.js"; @@ -239,8 +239,11 @@ describe("FEATS — PHB'24 category invariants", () => { expect(missing, "feats without a category").toEqual([]); }); - it("General feats have levelPrerequisite 4, a nonempty abilityOptions, and abilityIncrease 1", () => { - for (const f of FEATS.filter((f) => f.category === "general")) { + // #1310: scoped to EDITION_2024 — PHB'14's "general" rows (below) carry no + // levelPrerequisite (PHB'14 p.165 has no per-feat level gate) and 13 of the + // 18 shared General names grant no PHB'14 ability bump. + it("2024 General feats have levelPrerequisite 4, a nonempty abilityOptions, and abilityIncrease 1", () => { + for (const f of FEATS.filter((f) => f.category === "general" && f.edition === "EDITION_2024")) { expect(f.levelPrerequisite, `${f.name} levelPrerequisite`).toBe(4); expect((f.abilityOptions ?? []).length, `${f.name} abilityOptions`).toBeGreaterThan(0); expect(f.abilityIncrease, `${f.name} abilityIncrease`).toBe(1); @@ -279,10 +282,11 @@ describe("FEATS — PHB'24 category invariants", () => { expect(byName.get("Great Weapon Fighting")?.improvements ?? []).toEqual([]); }); - // #1306 worked example: the epic's own illustration (Alert forks, Grappler - // stays shared) — pins the actual seeded data, not just resolveEditionRow's - // pure logic. - it("Alert forks by edition (SRD 5.2 vs PHB'14 p.165); Grappler stays one shared row", () => { + // #1306 worked example, superseded by #1310: Grappler now forks too — every + // Feat row is edition-tagged (empty shared-NULL set, ACTIONS/#1430 precedent), + // so "Grappler stays one shared row" (#1306's original illustration) is + // deliberately inverted here rather than left passing for the wrong reason. + it("Alert AND Grappler both fork by edition (SRD 5.2 vs PHB'14 p.165; SRD 5.2 vs SRD 5.1)", () => { const alerts = FEATS.filter((f) => f.name === "Alert"); expect(alerts).toHaveLength(2); expect(alerts.map((f) => f.edition).sort()).toEqual(["EDITION_2014", "EDITION_2024"]); @@ -290,10 +294,22 @@ describe("FEATS — PHB'24 category invariants", () => { const alert2024 = alerts.find((f) => f.edition === "EDITION_2024")!; expect(alert2014.improvements).toEqual([{ target: "initiative", amount: 5 }]); expect(alert2024.improvements).toEqual([{ target: "initiative", amount: 1, scaling: "proficiencyBonus" }]); + // #1310: PHB'14 has no Origin taxonomy — the 2014 row's category moves to + // "general" (the corollary is it takes an ASI slot; the 2024 row stays + // "origin", background-granted only). + expect(alert2014.category).toBe("general"); + expect(alert2024.category).toBe("origin"); const grapplers = FEATS.filter((f) => f.name === "Grappler"); - expect(grapplers).toHaveLength(1); - expect(grapplers[0].edition).toBeUndefined(); + expect(grapplers).toHaveLength(2); + expect(grapplers.map((f) => f.edition).sort()).toEqual(["EDITION_2014", "EDITION_2024"]); + const grappler2014 = grapplers.find((f) => f.edition === "EDITION_2014")!; + const grappler2024 = grapplers.find((f) => f.edition === "EDITION_2024")!; + // 2014 (SRD 5.1): no ability bump, flat Strength 13+ prerequisite. + expect(grappler2014.abilityIncrease).toBeUndefined(); + expect(grappler2014.prerequisite).toBe("Strength 13+"); + // 2024: adds the half-feat bump and a Strength-or-Dexterity choice. + expect(grappler2024.abilityIncrease).toBe(1); }); it("only Magic Initiate and Skilled are repeatable", () => { @@ -381,6 +397,86 @@ describe("FEATS — 2014 Fighting Style feats (#1311)", () => { }); }); +// PHB'14 pp. 165-170 (#1310): restores the 2014 half of the catalog `6491c528` +// (#1154) replaced. PHB'14 has no Origin/Fighting Style/Epic Boon taxonomy, so +// every 2014 row is "general" with no levelPrerequisite — featOfferedForAsiSlot's +// `?? 4` default IS the 2014 "earliest ASI is level 4" rule, not a fudge. +describe("FEATS — 2014 general/origin catalog (#1310)", () => { + const feats2014 = () => FEATS.filter((f) => f.edition === "EDITION_2014" && f.category !== "fighting_style"); + + it("seeds exactly 26 EDITION_2014 general-category rows (the 24 6491c528 deleted, plus Grappler and Savage Attacker)", () => { + const rows = feats2014(); + expect(rows).toHaveLength(26); + expect(rows.every((f) => f.category === "general")).toBe(true); + expect(rows.every((f) => f.levelPrerequisite == null)).toBe(true); + }); + + it("contains Mobile, not Speedy; the 2024 catalog has Speedy, not Mobile", () => { + const names2014 = new Set(feats2014().map((f) => f.name)); + expect(names2014.has("Mobile")).toBe(true); + expect(names2014.has("Speedy")).toBe(false); + + const names2024 = new Set(FEATS.filter((f) => f.edition === "EDITION_2024").map((f) => f.name)); + expect(names2024.has("Speedy")).toBe(true); + expect(names2024.has("Mobile")).toBe(false); + }); + + it("Mobile carries the +10 speed improvement recovered verbatim from the pre-#1154 catalog", () => { + const mobile = feats2014().find((f) => f.name === "Mobile"); + expect(mobile?.improvements).toEqual([{ target: "speed", amount: 10 }]); + }); + + it("2014 Grappler has no ability bump and a flat Strength 13+ prerequisite (SRD 5.1)", () => { + const grappler = feats2014().find((f) => f.name === "Grappler"); + expect(grappler?.prerequisite).toBe("Strength 13+"); + expect(grappler?.abilityOptions ?? []).toEqual([]); + expect(grappler?.abilityIncrease).toBeUndefined(); + }); + + it("2014 Savage Attacker is melee-only and grants no ability bump (PHB'14, distinct from 2024's any-weapon Origin version)", () => { + const savageAttacker = feats2014().find((f) => f.name === "Savage Attacker"); + expect(savageAttacker?.description).toMatch(/melee weapon attack/i); + expect(savageAttacker?.abilityOptions ?? []).toEqual([]); + }); + + it("2014 Weapon Master states the weapon choice in its description and carries no hardcoded improvements", () => { + const weaponMaster = feats2014().find((f) => f.name === "Weapon Master"); + expect(weaponMaster?.description).toMatch(/of your choice/i); + expect(weaponMaster?.improvements ?? []).toEqual([]); + }); + + it("2014 Magic Initiate and Skilled are not repeatable (PHB'14 p.165: once-only unless stated otherwise)", () => { + for (const name of ["Magic Initiate", "Skilled"]) { + const row = feats2014().find((f) => f.name === name); + expect(row?.repeatable, name).toBeFalsy(); + } + }); + + it("carries zero rows of category origin, fighting_style, or epic_boon", () => { + const rows = FEATS.filter((f) => f.edition === "EDITION_2014"); + const offCategory = rows.filter((f) => (["origin", "epic_boon"] as const).includes(f.category as never)); + expect(offCategory.map((f) => f.name)).toEqual([]); + }); + + it("no Feat row is left edition-NULL — every row (2014 or 2024) carries an edition", () => { + const shared = FEATS.filter((f) => !f.edition).map((f) => f.name); + expect(shared).toEqual([]); + }); + + // The four originFeatName values BACKGROUNDS references — buildOriginEntry + // resolves them by name against the creating character's edition and returns + // null on a miss (character-create.ts), so a gap here would silently drop a + // background's Origin feat grant for whichever edition the miss lands on. + it("every BACKGROUNDS originFeatName has both an EDITION_2014 and EDITION_2024 row", () => { + const originFeatNames = [...new Set(BACKGROUNDS.map((b) => b.originFeatName).filter((n): n is string => !!n))]; + expect(originFeatNames.length).toBeGreaterThan(0); + for (const name of originFeatNames) { + const editions = FEATS.filter((f) => f.name === name).map((f) => f.edition).sort(); + expect(editions, name).toEqual(["EDITION_2014", "EDITION_2024"]); + } + }); +}); + // #1131: the creation spell picker needs real choice — strictly MORE spells on a // class's list than it takes at level 1, so a fresh caster is never forced. describe("SPELLS — creation picker coverage (#1131)", () => { diff --git a/backend/prisma/seed/feats.ts b/backend/prisma/seed/feats.ts index 1c9dde4b..95575538 100644 --- a/backend/prisma/seed/feats.ts +++ b/backend/prisma/seed/feats.ts @@ -1,10 +1,19 @@ // ── Feat catalog (2024 + 2014 rules) ──────────────────────────────────────────── // SRD 5.2.1 pp. 87-88 for the 16 SRD feats; PHB'24 for the re-authored rest; -// PHB'14 pp. 72/82/91 (= SRD 5.1) for the six 2014 Fighting Style feats (#1311). +// PHB'14 pp. 165-170 (= SRD 5.1 on Grappler only — see #1310) for the 2014 rows. // abilityOptions/abilityIncrease drive the half-feat bump; category/levelPrerequisite // gate which slot may take a feat (see featOfferedForAsiSlot). Ability Score // Improvement is NOT seeded — it stays the takeAsi advancement branch. // Deeper per-feat mechanics are surfaced as description text, not automated. +// +// Every row is edition-tagged (#1310/#1311) — no shared `edition: null` Feat row +// remains, per the ACTIONS/#1430 "a transcription can only cite one document" +// precedent. PHB'14 has no Origin/Fighting Style/Epic Boon taxonomy: every 2014 +// row is `category: "general"` with NO levelPrerequisite (PHB'14 p.165's "forgo +// an Ability Score Improvement" rule has no per-feat level gate — the earliest +// ASI in any 2014 class is level 4, which is featOfferedForAsiSlot's `?? 4` +// default). A 2014 background's Origin-feat grant is suppressed edition-wide by +// backgroundGrantsOriginFeat (#1504), independent of how these rows are tagged. import type { SeedEdition } from "./edition.js"; @@ -56,7 +65,10 @@ export const FEATS: FeatSeed[] = [ name: "Alert", description: "You gain a +5 bonus to initiative. You can't be surprised while you are conscious. Other creatures don't gain advantage on attack rolls against you as a result of being unseen by you.", - category: "origin", + // PHB'14 has no Origin taxonomy (#1310) — "general" is what makes this row + // takeable via an ASI slot at all; PHB'14 p.165's feat rule has no level + // gate, so levelPrerequisite stays omitted (featOfferedForAsiSlot's `?? 4`). + category: "general", improvements: [{ target: "initiative", amount: 5 }], edition: "EDITION_2014", }, @@ -66,12 +78,14 @@ export const FEATS: FeatSeed[] = [ "Choose Cleric, Druid, or Wizard. You learn two cantrips and one level-1 spell from that class's spell list. You can cast the level-1 spell once without a spell slot per Long Rest (or with slots you have). Your spellcasting ability is Intelligence, Wisdom, or Charisma to match the class. Repeatable, choosing a different class each time.", category: "origin", repeatable: true, + edition: "EDITION_2024", }, { name: "Savage Attacker", description: "Once per turn when you hit with a weapon, you can roll the weapon's damage dice twice and use either roll against the target.", category: "origin", + edition: "EDITION_2024", }, { name: "Skilled", @@ -79,12 +93,14 @@ export const FEATS: FeatSeed[] = [ "You gain proficiency in any combination of three skills or tools of your choice. Repeatable.", category: "origin", repeatable: true, + edition: "EDITION_2024", }, { name: "Lucky", description: "You have a number of Luck Points equal to your Proficiency Bonus, regained on a Long Rest. Spend a point to give yourself Advantage on a D20 Test, or to impose Disadvantage on an attack roll made against you.", category: "origin", + edition: "EDITION_2024", }, { name: "Tough", @@ -92,6 +108,7 @@ export const FEATS: FeatSeed[] = [ "Your Hit Point maximum increases by an amount equal to twice your character level when you gain this feat. Whenever you gain a level thereafter, your Hit Point maximum increases by an additional 2.", category: "origin", improvements: [{ target: "maxHp", amount: 2, perLevel: true }], + edition: "EDITION_2024", }, { name: "Tavern Brawler", @@ -103,6 +120,7 @@ export const FEATS: FeatSeed[] = [ { target: "weaponProficiency", amount: 1, key: "Improvised Weapons" }, { target: "unarmedDamageDie", amount: 4 }, ], + edition: "EDITION_2024", }, // ── Fighting Style feats (SRD 5.2.1) — granted by a Fighting Style feature ── @@ -209,6 +227,254 @@ export const FEATS: FeatSeed[] = [ edition: "EDITION_2014", }, + // ── 2014 catalog: General / Origin feats (PHB'14 pp. 165-170) — #1310 ─────── + // PHB'14 has ONE feat taxonomy (no Origin/Fighting Style/Epic Boon split), so + // every row here is `category: "general"` with no levelPrerequisite — see the + // file header. 26 names total: the 24 `6491c528` (#1154) deleted, recovered + // verbatim from `git show 6491c528^:backend/prisma/seed/feats.ts` (that file's + // own header called its text "concise summaries", not page transcriptions — + // preserved as-is here rather than rewritten), plus Grappler and Savage + // Attacker, freshly transcribed below since neither was in the deleted 24 + // (Grappler cites SRD 5.1 — the open-content 2014 feat; Savage Attacker is + // PHB'14-only, verified against two independent published transcriptions). + // Weapon Master's four hardcoded weapon proficiencies are dropped from the + // recovered improvements (the description already reads "of your choice", + // matching the 2024 sibling's description-only treatment) — every other + // recovered field/improvement is unchanged. + { + name: "Grappler", + // SRD 5.1 (dnd5eapi.co /api/2014/feats/grappler) — the one 2014 feat that + // is open content; every other 2014 row here cites PHB'14 only. + description: + "You've developed the skills necessary to hold your own in close-quarters grappling. You have advantage on attack rolls against a creature you are grappling. You can use your action to try to pin a creature grappled by you: make another grapple check, and if you succeed, you and the creature are both restrained until the grapple ends.", + category: "general", + prerequisite: "Strength 13+", + edition: "EDITION_2014", + }, + // Alert's EDITION_2014 row lives in the Origin-feats block above (it already + // existed pre-#1310, #1306) — this section's own count (26 names) includes + // it without repeating the row. The four originFeatName values BACKGROUNDS + // references (Magic Initiate, Savage Attacker, Skilled, Alert) all have an + // EDITION_2014 row after this change. + { + name: "Lucky", + description: + "You have 3 luck points. Whenever you make an attack roll, ability check, or saving throw, you can spend one luck point to roll an additional d20 and choose which result to use. You can also spend a luck point when a creature attacks you. Luck points refresh on a long rest.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Mobile", + // The one name #1154 dropped outright with no successor — PHB'24's Speedy + // (above, EDITION_2024-only) rewrites its opportunity-attack clause. + description: + "Your speed increases by 10 feet. When you take the Dash action, difficult terrain doesn't cost you extra movement for the rest of the turn. When you make a melee attack against a creature, you don't provoke opportunity attacks from that creature for the rest of the turn, whether or not you hit.", + category: "general", + improvements: [{ target: "speed", amount: 10 }], + edition: "EDITION_2014", + }, + { + name: "Sentinel", + description: + "You excel at seizing the opportune moment. Creatures you hit with opportunity attacks have their speed reduced to 0. Creatures within 5 feet of you provoke opportunity attacks even if they Disengage. When a creature within 5 feet attacks a target other than you, you can use a reaction to make a melee weapon attack against it.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Skilled", + description: + "You gain proficiency in any combination of three skills or tools of your choice.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Magic Initiate", + description: + "Choose a class: bard, cleric, druid, sorcerer, warlock, or wizard. You learn two cantrips and one 1st-level spell from that class's list. You can cast the 1st-level spell once per long rest using this feat (not using spell slots). Your spellcasting ability is the one associated with the chosen class.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "War Caster", + description: + "You have advantage on Constitution saving throws to maintain concentration on a spell when you take damage. You can perform the somatic components of spells even when you have weapons or a shield in one or both hands. When a hostile creature's movement provokes an opportunity attack from you, you can use your reaction to cast a spell at the creature instead of making an opportunity attack.", + category: "general", + prerequisite: "Ability to cast at least one spell", + edition: "EDITION_2014", + }, + { + name: "Great Weapon Master", + description: + "When you score a critical hit with a melee weapon or reduce a creature to 0 HP with a melee weapon, you can make one melee weapon attack as a bonus action. Before you make a melee attack with a heavy weapon, you can choose to take a −5 penalty to the attack roll. If the attack hits, you add +10 to the damage roll.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Sharpshooter", + description: + "Attacking at long range doesn't impose disadvantage on your ranged weapon attack rolls. Your ranged weapon attacks ignore half cover and three-quarters cover. Before you make a ranged attack with a ranged weapon, you can choose to take a −5 penalty to the attack roll. If the attack hits, you add +10 to the damage roll.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Polearm Master", + description: + "When you take the Attack action with a glaive, halberd, pike, or quarterstaff, you can use a bonus action to make a melee attack with the opposite end of the weapon (1d4 bludgeoning, uses same ability modifier). While you are wielding one of these weapons, other creatures provoke an opportunity attack from you when they enter your reach.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Crossbow Expert", + description: + "You ignore the loading quality of crossbows. Being within 5 feet of a hostile creature doesn't impose disadvantage on ranged attack rolls. When you use the Attack action and attack with a one-handed weapon, you can use a bonus action to attack with a hand crossbow you are holding.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Shield Master", + description: + "If you take the Attack action on your turn, you can use a bonus action to shove a creature with your shield. If you aren't incapacitated, you can add your shield's AC bonus to Dexterity saving throws against spells that target only you. You can use your reaction to halve the damage of a Dex-save-or-halve effect.", + category: "general", + edition: "EDITION_2014", + }, + { + name: "Tough", + description: + "Your hit point maximum increases by an amount equal to twice your level when you gain this feat. Whenever you gain a level thereafter, your hit point maximum increases by an additional 2 HP.", + category: "general", + improvements: [{ target: "maxHp", amount: 2, perLevel: true }], + edition: "EDITION_2014", + }, + // ── Half-feats (grant +1 to a chosen ability score) ───────────────────── + { + name: "Athlete", + description: + "+1 to Strength or Dexterity. When prone, standing up costs only 5 feet of movement. Climbing doesn't cost extra movement. Running long jump: add 1 extra foot per point of Str modifier.", + category: "general", + abilityOptions: ["strength", "dexterity"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Actor", + description: + "+1 to Charisma. You have advantage on Deception and Performance checks when trying to pass yourself off as a different person. You can mimic the speech of another person or the sounds made by other creatures. Passive Insight DC 14 to notice.", + category: "general", + abilityOptions: ["charisma"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Durable", + description: + "+1 to Constitution. When you roll a Hit Die to regain HP, the minimum number of HP you regain equals twice your Constitution modifier (minimum of 2).", + category: "general", + abilityOptions: ["constitution"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Keen Mind", + description: + "+1 to Intelligence. You always know which way is north. You always know the number of hours until sunrise or sunset. You can accurately recall anything you have seen or heard within the past month.", + category: "general", + abilityOptions: ["intelligence"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Observant", + description: + "+1 to Intelligence or Wisdom. If you can see a creature's mouth while it is speaking a language you understand, you can interpret what it's saying by reading lips. +5 bonus to your passive Perception and passive Investigation scores.", + category: "general", + abilityOptions: ["intelligence", "wisdom"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Resilient", + description: + "+1 to the chosen ability. You gain proficiency in saving throws using the chosen ability.", + category: "general", + abilityOptions: ALL_ABILITIES, + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Lightly Armored", + description: + "+1 to Strength or Dexterity. You gain proficiency with light armor.", + category: "general", + abilityOptions: ["strength", "dexterity"], + abilityIncrease: 1, + improvements: [{ target: "armorProficiency", amount: 1, key: "light" }], + edition: "EDITION_2014", + }, + { + name: "Moderately Armored", + description: + "+1 to Strength or Dexterity. You gain proficiency with medium armor and shields.", + category: "general", + prerequisite: "Proficiency with light armor", + abilityOptions: ["strength", "dexterity"], + abilityIncrease: 1, + improvements: [ + { target: "armorProficiency", amount: 1, key: "medium" }, + { target: "armorProficiency", amount: 1, key: "shield" }, + ], + edition: "EDITION_2014", + }, + { + name: "Heavily Armored", + description: + "+1 to Strength. You gain proficiency with heavy armor.", + category: "general", + prerequisite: "Proficiency with medium armor", + abilityOptions: ["strength"], + abilityIncrease: 1, + improvements: [{ target: "armorProficiency", amount: 1, key: "heavy" }], + edition: "EDITION_2014", + }, + { + name: "Weapon Master", + // 2014's four hardcoded weapon proficiencies are dropped (#1310 scope): the + // description already reads "of your choice", matching the 2024 sibling's + // description-only treatment — no improvement target stands in for a choice. + description: + "+1 to Strength or Dexterity. You gain proficiency with four weapons of your choice.", + category: "general", + abilityOptions: ["strength", "dexterity"], + abilityIncrease: 1, + edition: "EDITION_2014", + }, + { + name: "Tavern Brawler", + description: + "+1 to Strength or Constitution. You are proficient with improvised weapons and your " + + "unarmed strikes deal 1d4 bludgeoning damage. When you hit a creature with an unarmed " + + "strike or an improvised weapon on your turn, you can use a bonus action to attempt " + + "to grapple the target.", + category: "general", + abilityOptions: ["strength", "constitution"], + abilityIncrease: 1, + improvements: [ + { target: "weaponProficiency", amount: 1, key: "Improvised Weapons" }, + { target: "unarmedDamageDie", amount: 4 }, + ], + edition: "EDITION_2014", + }, + { + name: "Savage Attacker", + // PHB'14 (verified against two independent published transcriptions, + // dnd5e.wikidot.com and aidedd.org, since Savage Attacker is not SRD + // content): melee-only, and a straight reroll rather than 2024's + // roll-twice-keep-either on any weapon. + description: + "Once per turn when you roll damage for a melee weapon attack, you can reroll the weapon's damage dice and use either total.", + category: "general", + edition: "EDITION_2014", + }, + // ── General feats (level 4+) — each grants +1 to a listed ability ────────── { name: "Grappler", @@ -219,6 +485,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Strength or Dexterity 13+", abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Actor", @@ -229,6 +496,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Charisma 13+", abilityOptions: ["charisma"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Athlete", @@ -239,6 +507,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Strength or Dexterity 13+", abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Durable", @@ -248,6 +517,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 4, abilityOptions: ["constitution"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Keen Mind", @@ -258,6 +528,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Intelligence 13+", abilityOptions: ["intelligence"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Observant", @@ -268,6 +539,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Intelligence or Wisdom 13+", abilityOptions: ["intelligence", "wisdom"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Resilient", @@ -277,6 +549,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 4, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Sentinel", @@ -287,6 +560,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Strength or Dexterity 13+", abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "War Caster", @@ -297,6 +571,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Spellcasting or Pact Magic feature", abilityOptions: ["intelligence", "wisdom", "charisma"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Great Weapon Master", @@ -306,6 +581,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 4, abilityOptions: ["strength"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Sharpshooter", @@ -316,6 +592,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Dexterity 13+", abilityOptions: ["dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Polearm Master", @@ -326,6 +603,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Strength or Dexterity 13+", abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Crossbow Expert", @@ -336,6 +614,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Dexterity 13+", abilityOptions: ["dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Shield Master", @@ -346,6 +625,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Shield Training", abilityOptions: ["strength"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Heavily Armored", @@ -356,6 +636,7 @@ export const FEATS: FeatSeed[] = [ abilityOptions: ["strength", "constitution"], abilityIncrease: 1, improvements: [{ target: "armorProficiency", amount: 1, key: "heavy" }], + edition: "EDITION_2024", }, { name: "Lightly Armored", @@ -369,6 +650,7 @@ export const FEATS: FeatSeed[] = [ { target: "armorProficiency", amount: 1, key: "light" }, { target: "armorProficiency", amount: 1, key: "shield" }, ], + edition: "EDITION_2024", }, { name: "Moderately Armored", @@ -380,6 +662,7 @@ export const FEATS: FeatSeed[] = [ abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, improvements: [{ target: "armorProficiency", amount: 1, key: "medium" }], + edition: "EDITION_2024", }, { name: "Weapon Master", @@ -390,6 +673,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 4, abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Speedy", @@ -402,9 +686,12 @@ export const FEATS: FeatSeed[] = [ abilityOptions: ["dexterity", "constitution"], abilityIncrease: 1, improvements: [{ target: "speed", amount: 10 }], + edition: "EDITION_2024", }, // ── Epic Boons (SRD 5.2.1) — level 19+, +1 to an ability (max 30) ────────── + // PHB'14 has no Epic Boon feats (2014 epic boons are a DMG option the DM + // grants, not a feat) — EDITION_2024-only, no 2014 twin. { name: "Boon of Combat Prowess", description: @@ -413,6 +700,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of Dimensional Travel", @@ -422,6 +710,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of Fate", @@ -431,6 +720,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of Irresistible Offense", @@ -440,6 +730,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ["strength", "dexterity"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of Spell Recall", @@ -450,6 +741,7 @@ export const FEATS: FeatSeed[] = [ prerequisite: "Spellcasting Feature", abilityOptions: ["intelligence", "wisdom", "charisma"], abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of the Night Spirit", @@ -459,6 +751,7 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, { name: "Boon of Truesight", @@ -467,5 +760,6 @@ export const FEATS: FeatSeed[] = [ levelPrerequisite: 19, abilityOptions: ALL_ABILITIES, abilityIncrease: 1, + edition: "EDITION_2024", }, ]; diff --git a/backend/src/lib/leveling/advancement.ts b/backend/src/lib/leveling/advancement.ts index ed5ecddf..b1bba82a 100644 --- a/backend/src/lib/leveling/advancement.ts +++ b/backend/src/lib/leveling/advancement.ts @@ -379,9 +379,10 @@ async function resolveCatalogFeat( `takeFeat: "${catalogFeat.name}" (${category}) is not a Fighting Style feat`, ); } - } else if (!featOfferedForAsiSlot({ category, levelPrerequisite: catalogFeat.levelPrerequisite }, level, edition)) { - // PHB'24: only General/Epic Boon feats the character's level satisfies may be - // taken via an ASI slot — Origin (backgrounds) and Fighting Style (class) can't. + } else if (!featOfferedForAsiSlot({ category, levelPrerequisite: catalogFeat.levelPrerequisite }, level)) { + // Edition-invariant (#1310): only General/Epic Boon feats the character's + // level satisfies may be taken via an ASI slot — Origin (backgrounds) and + // Fighting Style (class) can't, in either edition. throw new InvalidAdvancementOperationError( `takeFeat: "${catalogFeat.name}" (${category}) is not available at level ${level}`, ); diff --git a/backend/src/lib/rules/__tests__/catalog-edition-constraints.test.ts b/backend/src/lib/rules/__tests__/catalog-edition-constraints.test.ts index 950509c1..50be35ba 100644 --- a/backend/src/lib/rules/__tests__/catalog-edition-constraints.test.ts +++ b/backend/src/lib/rules/__tests__/catalog-edition-constraints.test.ts @@ -163,7 +163,7 @@ describe("NULLS NOT DISTINCT — SubclassGrantedSpell(subclassId, spellId, editi }); describe("worked example against the real seeded catalog (#1306)", () => { - it("Alert resolves to a different row per edition; Grappler resolves to the same shared row", async () => { + it("Alert AND Grappler both resolve to a different row per edition (#1310: Grappler forks too, superseding #1306's original 'stays shared' illustration)", async () => { const alertRows = await prisma.feat.findMany({ where: { name: "Alert" }, select: { id: true, edition: true, improvements: true }, @@ -182,11 +182,12 @@ describe("worked example against the real seeded catalog (#1306)", () => { where: { name: "Grappler" }, select: { id: true, edition: true }, }); - expect(grapplerRows).toHaveLength(1); + expect(grapplerRows).toHaveLength(2); const grappler2014 = resolveEditionRow(grapplerRows, "EDITION_2014"); const grappler2024 = resolveEditionRow(grapplerRows, "EDITION_2024"); expect(grappler2014).toBeDefined(); - expect(grappler2014!.id).toBe(grappler2024!.id); + expect(grappler2024).toBeDefined(); + expect(grappler2014!.id).not.toBe(grappler2024!.id); }); it("neither edition resolves a name absent from the catalog", async () => { diff --git a/backend/src/lib/srd/__tests__/srd-feat-offer.test.ts b/backend/src/lib/srd/__tests__/srd-feat-offer.test.ts index 17dc9805..635daa77 100644 --- a/backend/src/lib/srd/__tests__/srd-feat-offer.test.ts +++ b/backend/src/lib/srd/__tests__/srd-feat-offer.test.ts @@ -1,57 +1,43 @@ import { describe, expect, it } from "vitest"; -import { deriveFeatBonuses, featOfferedForAsiSlot, type FeatCategory } from "@/lib/srd/feats.js"; +import { deriveFeatBonuses, featOfferedForAsiSlot } from "@/lib/srd/feats.js"; import type { AdvancementEntry } from "@/lib/classes/resources.js"; -import type { RulesEdition } from "@character-sheet/shared-types"; -const E2024 = "EDITION_2024" as const; - -// PHB'24 pp. 87-88: ASI slots offer General (level 4+) and Epic Boon (level 19+) -// feats only; Origin feats come from backgrounds and Fighting Style from class. +// Edition-invariant (#1310): PHB'24 pp. 87-88 draws origin/general/fighting_style +// /epic_boon; PHB'14's own rule (p.165, no per-feat level gate, earliest ASI at +// level 4) is what the "general" branch's `?? 4` default already encodes, so +// featOfferedForAsiSlot takes no `edition` parameter — every 2014 Feat row is +// `category: "general"` with a NULL levelPrerequisite, and origin/fighting_style +// /epic_boon rows are all EDITION_2024-tagged so a 2014 character never reaches +// those branches at all. describe("featOfferedForAsiSlot", () => { it("never offers Origin feats", () => { - expect(featOfferedForAsiSlot({ category: "origin", levelPrerequisite: null }, 1, E2024)).toBe(false); - expect(featOfferedForAsiSlot({ category: "origin", levelPrerequisite: null }, 20, E2024)).toBe(false); + expect(featOfferedForAsiSlot({ category: "origin", levelPrerequisite: null }, 1)).toBe(false); + expect(featOfferedForAsiSlot({ category: "origin", levelPrerequisite: null }, 20)).toBe(false); }); it("never offers Fighting Style feats via an ASI slot", () => { - expect(featOfferedForAsiSlot({ category: "fighting_style", levelPrerequisite: null }, 20, E2024)).toBe(false); + expect(featOfferedForAsiSlot({ category: "fighting_style", levelPrerequisite: null }, 20)).toBe(false); }); - it("offers General feats at level >= 4 (default prerequisite)", () => { - expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: null }, 3, E2024)).toBe(false); - expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: null }, 4, E2024)).toBe(true); + it("offers General feats at level >= 4 (default prerequisite) — this IS the PHB'14 rule, not just PHB'24's", () => { + expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: null }, 3)).toBe(false); + expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: null }, 4)).toBe(true); }); it("honours an explicit General levelPrerequisite override", () => { - expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: 8 }, 7, E2024)).toBe(false); - expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: 8 }, 8, E2024)).toBe(true); + expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: 8 }, 7)).toBe(false); + expect(featOfferedForAsiSlot({ category: "general", levelPrerequisite: 8 }, 8)).toBe(true); }); it("offers Epic Boon feats only at level >= 19 (default prerequisite)", () => { - expect(featOfferedForAsiSlot({ category: "epic_boon", levelPrerequisite: null }, 18, E2024)).toBe(false); - expect(featOfferedForAsiSlot({ category: "epic_boon", levelPrerequisite: null }, 19, E2024)).toBe(true); + expect(featOfferedForAsiSlot({ category: "epic_boon", levelPrerequisite: null }, 18)).toBe(false); + expect(featOfferedForAsiSlot({ category: "epic_boon", levelPrerequisite: null }, 19)).toBe(true); }); it("fails safe-closed for an unknown future category", () => { // A category added to the DB enum but not to the switch must never leak feats. - expect(featOfferedForAsiSlot({ category: "mystery" as never, levelPrerequisite: null }, 20, E2024)).toBe(false); - }); - - // #1438 threaded `edition` purely as the seam #1310 will fill; until then an - // accidental fork is a silent per-edition picker divergence with no other - // symptom, so pin every (category, level) verdict equal across both editions. - it("returns the same verdict in both editions for every category at every level", () => { - const categories: FeatCategory[] = ["origin", "general", "fighting_style", "epic_boon"]; - const editions: RulesEdition[] = ["EDITION_2014", "EDITION_2024"]; - for (const category of categories) { - for (let level = 1; level <= 20; level += 1) { - const [v2014, v2024] = editions.map((edition) => - featOfferedForAsiSlot({ category, levelPrerequisite: null }, level, edition), - ); - expect(v2014, `${category} @ ${level}`).toBe(v2024); - } - } + expect(featOfferedForAsiSlot({ category: "mystery" as never, levelPrerequisite: null }, 20)).toBe(false); }); }); diff --git a/backend/src/lib/srd/feats.ts b/backend/src/lib/srd/feats.ts index 854c2326..5b0c5eeb 100644 --- a/backend/src/lib/srd/feats.ts +++ b/backend/src/lib/srd/feats.ts @@ -3,28 +3,28 @@ import { z } from "zod"; import type { AdvancementEntry } from "@/lib/classes/resources.js"; import type { FeatImprovement } from "@/lib/classes/resources-state.js"; import { proficiencyBonusForLevel } from "@/lib/leveling/experience.js"; -import type { RulesEdition } from "@character-sheet/shared-types"; /** PHB'24 feat categories (local union keeps srd/ a dependency leaf). */ export type FeatCategory = "origin" | "general" | "fighting_style" | "epic_boon"; /** - * Whether a feat may be taken via an Ability Score Improvement slot at `level` - * (PHB'24 pp. 87-88). Origin feats come from backgrounds and Fighting Style from - * class features, so neither is ever offered here; General unlocks at level 4 and - * Epic Boon at level 19 unless the feat overrides levelPrerequisite. + * Whether a feat may be taken via an Ability Score Improvement slot at `level`. + * Origin feats come from backgrounds and Fighting Style from class features, so + * neither is ever offered here; General unlocks at level 4 and Epic Boon at + * level 19 unless the feat overrides levelPrerequisite. * - * Both editions resolve identically today: origin/general/fighting_style/epic_boon - * is PHB'24 taxonomy and no 2014 Feat rows exist outside the already-forked Alert - * pair, so there is nothing for a 2014 branch to decide yet. `edition` is threaded - * now (last parameter, per subclassGateLevel) so #1310's 2014 feat catalog changes - * this body instead of every call site — a unit test pins the two verdicts equal. + * Edition-invariant (#1310): PHB'24 pp. 87-88 draws this exact taxonomy, and + * PHB'14's every-feat-is-general-with-no-level-gate rule (p.165, earliest ASI + * at level 4 in any 2014 class) is faithfully encoded by 2014's Feat rows + * carrying `category: "general"` with a NULL levelPrerequisite — so the + * `general` branch's `?? 4` default already IS the 2014 rule, with no fork + * needed. `origin`/`fighting_style`/`epic_boon` rows are all EDITION_2024-tagged, + * so a 2014 character can never reach those branches at all. No `edition` + * parameter, per CLAUDE.md: "an edition-invariant rule takes no edition." */ export function featOfferedForAsiSlot( feat: { category: FeatCategory; levelPrerequisite?: number | null }, level: number, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the #1310 fork seam; see JSDoc, both editions resolve identically until 2014 feats exist - edition: RulesEdition, ): boolean { switch (feat.category) { case "origin": diff --git a/backend/src/routes/catalog/__tests__/feats-asi-level-defaults.test.ts b/backend/src/routes/catalog/__tests__/feats-asi-level-defaults.test.ts index 27c808e0..08c80a71 100644 --- a/backend/src/routes/catalog/__tests__/feats-asi-level-defaults.test.ts +++ b/backend/src/routes/catalog/__tests__/feats-asi-level-defaults.test.ts @@ -1,11 +1,14 @@ /** * GET /api/feats?asiLevel= against FIXTURE rows (#1438) — the companion to the - * real-seeded-catalog cases in feats.test.ts. It exists because the seed can - * never exercise featOfferedForAsiSlot's `?? 4` / `?? 19` category defaults: all - * 19 seeded general rows carry an explicit levelPrerequisite 4 and all 7 - * epic_boon rows an explicit 19. Only a NULL-levelPrerequisite row reaches those - * branches, and only a row with a levelPrerequisite other than the category - * default proves the route passes the ROW's value rather than a constant. + * real-seeded-catalog cases in feats.test.ts. `?? 19` (Epic Boon) stays + * fixture-only: PHB'14 has no epic_boon feats at all, so no real 2014 row can + * ever reach that branch, and every real 2024 epic_boon row carries an explicit + * levelPrerequisite 19. `?? 4` (General) is ALSO covered by the fixture here + * (proving the route passes a ROW's own override, not a constant, via + * GENERAL_AT_EIGHT) but is no longer unreachable from the real catalog since + * #1310: every one of the 26 EDITION_2014 general rows has a NULL + * levelPrerequisite — see feats.test.ts's "2014 general/origin feats" suite + * for that proof against the seed itself. * * Fixtures are uniquely named and deleted BY NAME in afterAll — never a * deleteMany over a seeded row (docs/testing.md). diff --git a/backend/src/routes/catalog/__tests__/feats.test.ts b/backend/src/routes/catalog/__tests__/feats.test.ts index 0e401fed..68b97ced 100644 --- a/backend/src/routes/catalog/__tests__/feats.test.ts +++ b/backend/src/routes/catalog/__tests__/feats.test.ts @@ -3,9 +3,10 @@ * `?edition=` is mandatory: an absent one and an unrecognized one both 400, * and both assert their message — two 400s are not distinguishable by status * alone, and the whole point of the required param is that a caller learns - * which mistake it made. The 2014/2024/Grappler cases are unchanged from - * #1306 on purpose: they are the proof that making the param required left - * resolveEditionCatalog's exact-then-shared resolution undisturbed. + * which mistake it made. The Alert case is unchanged from #1306 on purpose — + * proof that making the param required left resolveEditionCatalog's + * exact-then-shared resolution undisturbed. Grappler's case inverts under + * #1310: it now forks like Alert instead of staying one shared row. */ import { beforeAll, describe, expect, it } from "vitest"; import supertest from "supertest"; @@ -45,13 +46,20 @@ describe("GET /api/feats — edition resolution (#1306)", () => { expect(alerts[0].improvements).toEqual([{ target: "initiative", amount: 1, scaling: "proficiencyBonus" }]); }); - it("either edition resolves Grappler to the same single shared row", async () => { + // #1310 inverts this: Grappler now forks (SRD 5.2 half-feat vs SRD 5.1 flat + // Strength 13+ prerequisite) — every Feat row is edition-tagged, so no name + // resolves to the same row across editions any more. + it("each edition resolves Grappler to its OWN row, not a shared one", async () => { const res2014 = await supertest(app).get("/api/feats?edition=EDITION_2014").set("Cookie", COOKIE); const res2024 = await supertest(app).get("/api/feats?edition=EDITION_2024").set("Cookie", COOKIE); const grappler2014 = res2014.body.find((f: { name: string }) => f.name === "Grappler"); const grappler2024 = res2024.body.find((f: { name: string }) => f.name === "Grappler"); - expect(grappler2014.id).toBe(grappler2024.id); + expect(grappler2014.id).not.toBe(grappler2024.id); + // abilityIncrease defaults to 0 at the DB layer (seed.ts's orElse), not + // NULL — 0 is the "no half-feat bump" wire value, unlike levelPrerequisite. + expect(grappler2014.abilityIncrease).toBe(0); + expect(grappler2024.abilityIncrease).toBe(1); }); it("an unrecognized ?edition= value 400s with a message distinct from the missing-param one", async () => { @@ -63,11 +71,17 @@ describe("GET /api/feats — edition resolution (#1306)", () => { /** * Every case here runs against the REAL SEEDED catalog (backend/prisma/seed/feats.ts) - * — no fixture rows, which is why it can assert absolute category counts. The - * `?? 4` / `?? 19` category defaults are unreachable from the seed (all 19 general - * rows carry an explicit levelPrerequisite 4 and all 7 epic_boon rows an explicit - * 19), so those branches are covered by the fixture rows in - * feats-asi-level-defaults.test.ts instead. + * — no fixture rows, which is why it can assert absolute category counts. Scoped + * to `edition=EDITION_2024` throughout: all 19 2024 general rows carry an explicit + * levelPrerequisite 4 and all 7 epic_boon rows an explicit 19, so the `?? 4` / + * `?? 19` category defaults are unreachable from THIS edition's real catalog — + * covered by the fixture rows in feats-asi-level-defaults.test.ts instead. + * + * `?? 4` IS reachable from the real catalog since #1310: every one of the 26 + * EDITION_2014 general rows has a NULL levelPrerequisite (PHB'14 has no + * per-feat level gate). See "GET /api/feats?asiLevel= — 2014 general/origin + * feats" below for that proof; `?? 19` stays fixture-only (2014 has no + * epic_boon feats at all). */ describe("GET /api/feats?asiLevel= — server-side ASI eligibility (#1438)", () => { async function get(query: string): Promise<{ name: string; category: string }[]> { @@ -180,3 +194,48 @@ describe("GET /api/feats — 2014 Fighting Style feats resolve distinctly from 2 expect(gwf2024.description).toMatch(/treat any 1 or 2.*as a 3/i); }); }); + +/** + * PHB'14 pp. 165-170 (#1310): the 26-name general/origin restore. Runs against + * the REAL SEEDED catalog, same pattern as the two suites above — proves + * featOfferedForAsiSlot's `general` branch reads a NULL levelPrerequisite as + * "offered from level 4" for real 2014 rows, not just fixture ones + * (feats-asi-level-defaults.test.ts already covers the fixture side). + */ +describe("GET /api/feats?asiLevel= — 2014 general/origin feats (#1310)", () => { + async function get(query: string): Promise<{ name: string; category: string }[]> { + const res = await supertest(app).get(`/api/feats?${query}`).set("Cookie", COOKIE); + expect(res.status).toBe(200); + return res.body; + } + + it("edition=EDITION_2014 returns exactly 32 rows (26 general/origin + 6 fighting_style), no duplicate name", async () => { + const rows = await get("edition=EDITION_2014"); + expect(rows).toHaveLength(32); + const names = rows.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("edition=EDITION_2024 returns exactly 37 rows, no duplicate name", async () => { + const rows = await get("edition=EDITION_2024"); + expect(rows).toHaveLength(37); + const names = rows.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("edition=EDITION_2014 serves zero origin/epic_boon rows — every non-fighting_style row is general", async () => { + const rows = await get("edition=EDITION_2014"); + expect(rows.filter((f) => f.category === "origin")).toHaveLength(0); + expect(rows.filter((f) => f.category === "epic_boon")).toHaveLength(0); + }); + + it("gates the 2014 general feats on the `?? 4` category default — the AC that no fixture-only test can give", async () => { + const atThree = await get("edition=EDITION_2014&asiLevel=3"); + const atFour = await get("edition=EDITION_2014&asiLevel=4"); + // All 26 rows share a NULL levelPrerequisite (no PHB'14 feat has a level + // gate), so all 26 flip from excluded to offered at the SAME asiLevel — + // the `?? 4` default applying uniformly, not per-row overrides. + expect(atThree).toHaveLength(0); + expect(atFour).toHaveLength(26); + }); +}); diff --git a/backend/src/routes/catalog/__tests__/reference.test.ts b/backend/src/routes/catalog/__tests__/reference.test.ts index fd07528e..568b079a 100644 --- a/backend/src/routes/catalog/__tests__/reference.test.ts +++ b/backend/src/routes/catalog/__tests__/reference.test.ts @@ -69,8 +69,9 @@ describe("GET /api/reference", () => { // mechanics — the SAME Criminal/Soldier rows that grant both above (#1130) // must serve neither under EDITION_2014, proving the gate is on the // requesting edition and not merely on which rows are edition-tagged - // (Soldier's Savage Attacker is a shared `edition: null` row, granted to - // both editions before this fix). + // (Soldier's Savage Attacker is edition-tagged the same as Alert since + // #1310 — a real EDITION_2014 row exists and IS reachable by 2014 in every + // other respect, but still suppressed here). it("suppresses the ability spread and origin feat for every background under EDITION_2014 (#1504, #1572)", async () => { const response = await supertest .agent(app) @@ -259,9 +260,10 @@ describe("GET /api/reference", () => { expect(byName(criminal2014.body, "Folk Hero").originFeat).toBeNull(); expect(byName(criminal2024.body, "Folk Hero")).toBeUndefined(); - // Soldier: Savage Attacker is edition: null (shared path) — reachable by - // 2014 in every OTHER respect, but still suppressed here: the gate is on - // the REQUESTING edition, not on whether the feat row is edition-tagged. + // Soldier: Savage Attacker is edition-tagged (#1310, both an EDITION_2014 + // and EDITION_2024 row exist) — reachable by 2014 in every OTHER respect, + // but still suppressed here: the gate is on the REQUESTING edition, not + // on whether the feat row is edition-tagged. expect(byName(criminal2014.body, "Soldier").originFeat).toBeNull(); expect(byName(criminal2024.body, "Soldier").originFeat.name).toBe("Savage Attacker"); }); diff --git a/backend/src/routes/catalog/feats.ts b/backend/src/routes/catalog/feats.ts index 7c8db5f4..24bfed12 100644 --- a/backend/src/routes/catalog/feats.ts +++ b/backend/src/routes/catalog/feats.ts @@ -49,7 +49,6 @@ featsRouter.get("/feats", async (req, res) => { featOfferedForAsiSlot( { category: row.category as FeatCategory, levelPrerequisite: row.levelPrerequisite }, asiLevel, - edition, ), ); diff --git a/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts b/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts new file mode 100644 index 00000000..459ef4b6 --- /dev/null +++ b/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts @@ -0,0 +1,124 @@ +/** + * #1310 acceptance criteria exercised over the REAL creation + advancement + * transaction path — not fixture rows, and not resolveEditionRow called + * directly — proving the 26-row 2014 general/origin catalog is actually + * takeable, not merely present in a GET response. + */ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; + +import { app } from "@/test-support/app-server.js"; +import { prisma } from "@/lib/core/prisma.js"; +import { ensureTestOwner } from "@/test-support/owner.js"; +import { authCookie } from "@/test-support/auth.js"; +import { seededSpeciesAnchor } from "@/test-support/species.js"; + +const OWNER_ID = "owner-feats-2014-general-take"; +let COOKIE: string; + +const XP_LVL_4 = 2700; // level 4 — 1 ASI slot (BASE_ASI_LEVELS, both editions) + +beforeAll(async () => { + await ensureTestOwner(OWNER_ID); + COOKIE = await authCookie(OWNER_ID); +}); + +afterEach(async () => { + await prisma.character.deleteMany({ where: { name: { startsWith: "Feats2014" } } }); +}); + +async function createCharacter(rulesEdition: "EDITION_2014" | "EDITION_2024", name: string) { + const anchor = await seededSpeciesAnchor(rulesEdition); + const res = await supertest(app) + .post("/api/characters") + .set("Cookie", COOKIE) + .send({ + name, + alignment: "True Neutral", + ...anchor, + background: "Sage", + classes: [{ name: "Fighter" }], + abilityScores: { strength: 15, dexterity: 12, constitution: 14, intelligence: 10, wisdom: 10, charisma: 8 }, + rulesEdition, + experiencePoints: XP_LVL_4, + }); + expect(res.status).toBe(201); + return res.body.id as string; +} + +async function takeFeat(characterId: string, featId: string, abilityChoice?: string) { + return supertest(app) + .post(`/api/characters/${characterId}/advancement/transactions`) + .set("Cookie", COOKIE) + .send({ operations: [{ type: "takeFeat", featId, ...(abilityChoice ? { abilityChoice } : {}) }] }); +} + +describe("a 2014 character takes a real 2014 general-category feat via an ASI slot (#1310)", () => { + it("takes Sentinel with NO abilityChoice and gains no ability increase (Sentinel has no PHB'14 half-feat bump)", async () => { + const sentinel2014 = await prisma.feat.findFirstOrThrow({ + where: { name: "Sentinel", edition: "EDITION_2014" }, + }); + const id = await createCharacter("EDITION_2014", "Feats2014 Sentinel"); + + const res = await takeFeat(id, sentinel2014.id); + expect(res.status).toBe(200); + + const char = (await supertest(app).get(`/api/characters/${id}`).set("Cookie", COOKIE)).body; + const entry = char.advancements.find((a: { featName?: string }) => a.featName === "Sentinel"); + expect(entry).toBeDefined(); + expect(entry.abilityDeltas).toEqual({}); + }); + + it("rejects a 2014 half-feat (Grappler has no 2014 bump either, so use Athlete) with no abilityChoice, and accepts it with one", async () => { + const athlete2014 = await prisma.feat.findFirstOrThrow({ + where: { name: "Athlete", edition: "EDITION_2014" }, + }); + const id = await createCharacter("EDITION_2014", "Feats2014 Athlete"); + + const missing = await takeFeat(id, athlete2014.id); + expect(missing.status).toBe(400); + + const withChoice = await takeFeat(id, athlete2014.id, "strength"); + expect(withChoice.status).toBe(200); + const char = (await supertest(app).get(`/api/characters/${id}`).set("Cookie", COOKIE)).body; + const entry = char.advancements.find((a: { featName?: string }) => a.featName === "Athlete"); + expect(entry.abilityDeltas).toEqual({ strength: 1 }); + }); + + it("a 2014 character CANNOT take a 2014-tagged feat before level 4 (the `?? 4` general default, live)", async () => { + const sentinel2014 = await prisma.feat.findFirstOrThrow({ + where: { name: "Sentinel", edition: "EDITION_2014" }, + }); + const anchor = await seededSpeciesAnchor("EDITION_2014"); + const res = await supertest(app) + .post("/api/characters") + .set("Cookie", COOKIE) + .send({ + name: "Feats2014 TooLow", + alignment: "True Neutral", + ...anchor, + background: "Sage", + classes: [{ name: "Fighter" }], + abilityScores: { strength: 15, dexterity: 12, constitution: 14, intelligence: 10, wisdom: 10, charisma: 8 }, + rulesEdition: "EDITION_2014", + experiencePoints: 0, // level 1 — no ASI slot at all + }); + expect(res.status).toBe(201); + const id = res.body.id as string; + + const takeRes = await takeFeat(id, sentinel2014.id); + expect(takeRes.status).toBe(400); + }); + + it("a 2024 character is rejected (edition mismatch, not a category message) when submitting the 2014 Sentinel id", async () => { + const sentinel2014 = await prisma.feat.findFirstOrThrow({ + where: { name: "Sentinel", edition: "EDITION_2014" }, + }); + const id = await createCharacter("EDITION_2024", "Feats2014 CrossEdition"); + + const res = await takeFeat(id, sentinel2014.id); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/2014 rules/); + expect(res.body.error).toMatch(/2024 rules/); + }); +}); diff --git a/backend/src/routes/character/__tests__/species-creation-choices-2024.test.ts b/backend/src/routes/character/__tests__/species-creation-choices-2024.test.ts index df16d0f8..399bdd30 100644 --- a/backend/src/routes/character/__tests__/species-creation-choices-2024.test.ts +++ b/backend/src/routes/character/__tests__/species-creation-choices-2024.test.ts @@ -53,8 +53,10 @@ async function elf2024() { return { elf, woodElf }; } async function featByName(name: string) { - // safe while these feats have no edition fork — pin edition if a 2014 sibling appears - return prisma.feat.findFirstOrThrow({ where: { name } }); + // This whole file exercises 2024 characters only — pinned to EDITION_2024 + // since #1310 gave every General/Origin feat a 2014 sibling (a bare-name + // lookup would resolve non-deterministically between the two rows). + return prisma.feat.findFirstOrThrow({ where: { name, edition: "EDITION_2024" } }); } // Soldier's own Origin feat is Savage Attacker (catalog-data.ts) — a DIFFERENT From 2232a5bb271b877b806d4d912300a06bd0a4620a Mon Sep 17 00:00:00 2001 From: Steffen Andersland Date: Tue, 4 Aug 2026 21:23:40 -0400 Subject: [PATCH 2/2] fix(feats): narrow stale comment and mislabeled test from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross-edition-catalog-id.test.ts: the "both rows are category origin" comment went stale when this PR moved Alert 2014's category to "general" — only the Alert 2024 row is origin-gated now. Narrowed the comment to that fact; the conclusion (use a general-category fixture trio instead) is unchanged. - feats-2014-general-take.test.ts: the level-1/no-XP case's label claimed to prove featOfferedForAsiSlot's `?? 4` default, but a level-1 character has zero ASI slots, so the 400 comes from the slot-availability check, not the category default. Renamed to match what the test actually exercises; the real `?? 4` proof is the atThree=0/atFour=26 pair in feats.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018rre9Ho8Vx8zNtzzKkpvFn --- .../character/__tests__/cross-edition-catalog-id.test.ts | 9 +++++---- .../character/__tests__/feats-2014-general-take.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/character/__tests__/cross-edition-catalog-id.test.ts b/backend/src/routes/character/__tests__/cross-edition-catalog-id.test.ts index f55edd03..7c980e03 100644 --- a/backend/src/routes/character/__tests__/cross-edition-catalog-id.test.ts +++ b/backend/src/routes/character/__tests__/cross-edition-catalog-id.test.ts @@ -37,10 +37,11 @@ beforeAll(async () => { }); describe("Chunk 1 — Feat / advancement.takeFeat (lib/leveling/advancement.ts:353)", () => { - // The seeded Alert fork (#1306's worked example) is unusable here: both rows - // are category "origin", rejected by featOfferedForAsiSlot BEFORE the guard - // runs — a naive test against Alert would pass for the wrong reason. This - // trio is "general" category, reachable through the ASI slot at level 4. + // The seeded Alert fork (#1306's worked example) is unusable here: the + // Alert 2024 row is "origin"-gated, rejected by featOfferedForAsiSlot + // BEFORE the guard runs — a naive test against Alert would pass for the + // wrong reason. This trio is "general" category, reachable through the ASI + // slot at level 4. const FEAT_2014 = "XEd General 2014"; const FEAT_2024 = "XEd General 2024"; const FEAT_SHARED = "XEd General Shared"; diff --git a/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts b/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts index 459ef4b6..d56c84b7 100644 --- a/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts +++ b/backend/src/routes/character/__tests__/feats-2014-general-take.test.ts @@ -85,7 +85,12 @@ describe("a 2014 character takes a real 2014 general-category feat via an ASI sl expect(entry.abilityDeltas).toEqual({ strength: 1 }); }); - it("a 2014 character CANNOT take a 2014-tagged feat before level 4 (the `?? 4` general default, live)", async () => { + // NOTE: this is a level-1 character with ZERO ASI slots, so the 400 below + // comes from "no available ASI slot", not featOfferedForAsiSlot's `?? 4` + // category default — that proof (atThree=0/atFour=26, same character, an + // ASI slot present at both levels) lives in feats.test.ts's "2014 + // general/origin feats" suite instead. + it("a 2014 character at level 1 has no ASI slot to take a feat", async () => { const sentinel2014 = await prisma.feat.findFirstOrThrow({ where: { name: "Sentinel", edition: "EDITION_2014" }, });