Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- #1712 (folded in from #1711's review): `SpellClass`'s only existing index
-- is the UNIQUE (spellId, className) composite from #1711's migration —
-- className is not the LEADING column there, so a `?class=` lookup
-- (`classMemberships: { some: { className } }`, spells.ts) can't use it and
-- falls back to a sequential scan. Harmless at today's ~600 membership rows;
-- the 2014 content slices (#1713-#1721) are about to multiply that row
-- count, so the index earns its keep before they land rather than after.
--
-- Hand-written, not `prisma migrate dev`-generated: a pure additive index
-- needs no destructive-DDL workaround, but this repo's migrations for this
-- table are already hand-written (#1711's join migration) and Prisma 7.8's
-- `migrate dev` would otherwise want to regenerate the whole diff against
-- the drifted (already-hand-migrated) shadow database.

-- CreateIndex
CREATE INDEX "SpellClass_className_idx" ON "SpellClass"("className");
7 changes: 7 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,13 @@ model SpellClass {
className String // lowercase class name, e.g. "wizard"

@@unique([spellId, className])
// #1712 (folded in from #1711's review): className isn't the leading
// column of the unique(spellId, className) index above, so the ?class=
// route filter (classMemberships.some, spells.ts) can't use it — a
// dedicated index before the 2014 content slices (#1713-#1721) multiply
// the row count. See the hand-written migration's own comment for why this
// needed a manual migration.sql rather than `prisma migrate dev`.
@@index([className])
}

// Baseline equipment catalog, same role as Species/CharacterClass above: seeded
Expand Down
7 changes: 6 additions & 1 deletion backend/src/lib/character/character-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from "@/lib/inventory/starting-equipment-package.js";
import { creationSpellEntry } from "@/lib/spellcasting/spellcasting.js";
import { clampPreparedToLimit, type SpellEntry } from "@/lib/spellcasting/spell-state.js";
import { classesOf, SPELL_CLASS_MEMBERSHIP_SELECT } from "@/lib/spellcasting/spell-classes.js";
import { classesOf, rejectCrossEditionSpellForks, SPELL_CLASS_MEMBERSHIP_SELECT } from "@/lib/spellcasting/spell-classes.js";
import { subclassGateLevel } from "@/lib/leveling/effective-levels.js";
import { DEFAULT_RULES_EDITION } from "@/lib/rules/edition.js";
import { crossEditionRejection, resolveEditionRow, withEditionOrShared } from "@/lib/rules/catalog-edition.js";
Expand Down Expand Up @@ -1483,6 +1483,11 @@ async function resolveCreationSpells(
const rows = allIds.length
? await prisma.spell.findMany({ where: { id: { in: allIds } }, include: SPELL_CLASS_MEMBERSHIP_SELECT })
: [];
// #1712: reject a submitted id that's provably the WRONG edition's fork —
// see rejectCrossEditionSpellForks's own comment for why this doesn't
// reject every 2014 pick just because today's catalog is 2024-tagged.
const forkError = await rejectCrossEditionSpellForks(rows, edition);
if (forkError) return { ok: false, status: 400, error: forkError };
const byId = new Map(rows.map((r) => [r.id, { ...r, classes: classesOf(r) }]));
const maxLevel = maxSpellLevelForClass(className, 1, subclass, edition);

Expand Down
17 changes: 13 additions & 4 deletions backend/src/lib/leveling/level-up-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { applyResourceOpInTx, type ResourceOperation } from "@/lib/classes/resources.js";
import { applySpellcastingOpInTx, type LearnSpellOperation, type SpellcastingOperation } from "@/lib/spellcasting/spellcasting.js";
import { normalizeSpellcastingMutable } from "@/lib/spellcasting/spell-state.js";
import { classesOf, SPELL_CLASS_MEMBERSHIP_SELECT } from "@/lib/spellcasting/spell-classes.js";
import { classesOf, rejectCrossEditionSpellForks, SPELL_CLASS_MEMBERSHIP_SELECT } from "@/lib/spellcasting/spell-classes.js";
import {
advancingHitDie,
applyLevelUpHpInTx,
Expand Down Expand Up @@ -340,14 +340,22 @@ type SpellPickRow = { id: string; name: string; level: number; classes: string[]
async function loadPickCatalogRows(
cantripOps: LearnSpellOperation[],
spellOps: LearnSpellOperation[],
edition: RulesEdition,
): Promise<{ rowById: Map<string, SpellPickRow>; levelOf: (op: LearnSpellOperation) => number | undefined }> {
const ids = [...cantripOps, ...spellOps].map((o) => o.spellId).filter((id): id is string => Boolean(id));
const rows = ids.length
? await prisma.spell.findMany({
where: { id: { in: ids } },
select: { id: true, name: true, level: true, ...SPELL_CLASS_MEMBERSHIP_SELECT },
select: { id: true, name: true, level: true, edition: true, ...SPELL_CLASS_MEMBERSHIP_SELECT },
})
: [];
// #1712: reject an id that's provably the WRONG edition's fork before it
// ever reaches assertOnSpellList/assertCantripEligibility below — see
// rejectCrossEditionSpellForks's own comment for why this doesn't reject
// every 2014 pick just because today's catalog is 2024-tagged (would
// regress #1729's shipped 2014 known-caster level-up).
const forkError = await rejectCrossEditionSpellForks(rows, edition);
if (forkError) throw new InvalidLevelUpError(forkError);
// Flattened to SpellPickRow's `classes: string[]` here (#1711) so the
// eligibility checks below (assertOnSpellList, assertCantripEligibility)
// never see the join shape — one seam resolves membership, not two.
Expand Down Expand Up @@ -508,10 +516,11 @@ async function assertPickSpellEligibility(
submission: LevelUpSubmission,
steps: LevelUpStep[],
className: string,
edition: RulesEdition,
): Promise<void> {
const cantripOps = submission.cantripsLearned ?? [];
const spellOps = submission.spellsLearned ?? [];
const { rowById, levelOf } = await loadPickCatalogRows(cantripOps, spellOps);
const { rowById, levelOf } = await loadPickCatalogRows(cantripOps, spellOps, edition);
assertCantripVsLeveledPlacement(cantripOps, spellOps, levelOf);

const gate = resolveNewSpellsGate(steps);
Expand Down Expand Up @@ -543,7 +552,7 @@ export async function applyLevelUpTransaction(
await resolveLevelUpContext(characterId, submission.target, submission.subclassId);

const steps = validateLevelUpSubmission(planCharacter, targetEntry, chosenSubclassName, submission, pickedSubclassFeatureRows);
await assertPickSpellEligibility(submission, steps, targetEntry.name);
await assertPickSpellEligibility(submission, steps, targetEntry.name, planCharacter.edition);

const ops = buildLevelUpOps(steps, submission);

Expand Down
103 changes: 103 additions & 0 deletions backend/src/lib/spellcasting/spell-classes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { prisma } from "@/lib/core/prisma.js";
import { resolveEditionRow } from "@/lib/rules/catalog-edition.js";
import { RULES_EDITION_LABELS } from "@/lib/rules/edition.js";
import type { RulesEdition } from "@character-sheet/shared-types";

/**
* Shared read-side shape for the Spell↔SpellClass join (#1711, F2 of epic
* #1517's 2014 catalog fork). Every membership READ composes
Expand All @@ -15,3 +20,101 @@ export const SPELL_CLASS_MEMBERSHIP_SELECT = {
export function classesOf(spell: { classMemberships: { className: string }[] }): string[] {
return spell.classMemberships.map((m) => m.className);
}

/**
* Cross-edition admission check for CLIENT-SUPPLIED spell ids, already
* resolved to rows by resolveCreationSpells / loadPickCatalogRows (#1712).
*
* Deliberately NOT crossEditionRejection's plain "row.edition must be null or
* match" (catalog-edition.ts): the spell catalog is still mid-migration
* (epic #1517) — today's ~109 rows are ALL tagged EDITION_2024 with no 2014
* counterpart yet (the 2014 content slices, #1713-#1721, are what will
* populate spells-2014/*.ts), so a plain tag-mismatch reject would 400 every
* leveled spell a 2014 character tries to learn or scribe, regressing
* #1729's shipped 2014 known-caster level-up. This rejects a row ONLY once a
* genuine fork exists: when another row shares its name AND
* resolveEditionRow prefers THAT row for the requesting edition, the
* submitted row is provably the wrong fork. With no better candidate (the
* whole catalog today), the single existing row is admitted regardless of
* its own tag — the same "shared until proven otherwise" posture the route
* takes when it falls back through the NULL row.
*
* Batches every mismatched row's name into ONE extra query rather than one
* per row. Returns the first rejection message found (message-returning, not
* throwing — same rationale as crossEditionRejection: callers wrap it in
* their own domain error shape).
*/
export async function rejectCrossEditionSpellForks(
rows: { id: string; name: string; edition: RulesEdition | null }[],
edition: RulesEdition,
): Promise<string | null> {
const mismatched = rows.filter((row) => row.edition !== null && row.edition !== edition);
if (mismatched.length === 0) return null;

const names = [...new Set(mismatched.map((row) => row.name))];
const candidates = await prisma.spell.findMany({
where: { name: { in: names } },
select: { id: true, name: true, edition: true },
});
const candidatesByName = new Map<string, typeof candidates>();
for (const candidate of candidates) {
const group = candidatesByName.get(candidate.name);
if (group) group.push(candidate);
else candidatesByName.set(candidate.name, [candidate]);
}

for (const row of mismatched) {
const resolved = resolveEditionRow(candidatesByName.get(row.name) ?? [], edition);
if (resolved && resolved.id !== row.id) {
return `${row.name} is ${RULES_EDITION_LABELS[row.edition!]} content, not usable by a ${RULES_EDITION_LABELS[edition]} character`;
}
}
return null;
}

/**
* List-serving counterpart to rejectCrossEditionSpellForks, for GET
* /api/spells (#1712) — same "shared until proven otherwise" posture, applied
* to WHICH rows are offered rather than which submitted id is admitted.
*
* Groups candidates by name and prefers, in order: the exact-edition row, the
* shared (edition: null) row, and — the one place this diverges from
* resolveEditionCatalog (catalog-edition.ts) — the group's remaining row when
* neither exists. That divergence is deliberate: resolveEditionCatalog's
* plain exact-then-NULL rule is correct for Feat/Subclass/Background, whose
* catalogs already have full coverage on both editions, so "no match" always
* means a genuine edition-exclusive row. The spell catalog does NOT have that
* coverage yet (epic #1517 mid-migration) — today's ~109 rows are ALL tagged
* EDITION_2024 with no 2014 counterpart, so treating a bare tag mismatch as
* "not in this edition's catalog" would empty the creation/level-up picker
* for every 2014 caster and block character creation outright (caught by
* creation.spec.ts's 2014 Warlock e2e test, which documents "level1SpellPicks
* is edition-invariant, so a 2014 Warlock still walks it exactly like the
* 2024 case" as existing, deliberate product behavior).
*
* This is inert once a genuine fork lands: a name with BOTH a 2014 and a
* 2024 row still resolves to exactly the requesting edition's own row (the
* exact-match branch wins before the fallback ever runs) — proven by
* spells.test.ts's fork-disjointness suite. Only a name with a SINGLE,
* single-edition-tagged row (today's whole real catalog) falls through to
* the graceful branch and is served to both editions until #1713-#1721 give
* it a real 2014 sibling.
*/
export function resolveSpellCatalogForEdition<T extends { name: string; edition: RulesEdition | null }>(
rows: T[],
edition: RulesEdition,
): T[] {
const byName = new Map<string, T[]>();
for (const row of rows) {
const group = byName.get(row.name);
if (group) group.push(row);
else byName.set(row.name, [row]);
}
const resolved: T[] = [];
for (const group of byName.values()) {
const exact = group.find((row) => row.edition === edition);
const shared = group.find((row) => row.edition === null);
resolved.push(exact ?? shared ?? group[0]);
}
return resolved;
}
120 changes: 118 additions & 2 deletions backend/src/routes/catalog/__tests__/spells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,14 @@ async function seedFixtures() {
await seedSpellClasses(utility.id, UTILITY_SPELL_CLASSES);
}

function get(path: string) {
return supertest.agent(app).set("Cookie", COOKIE).get(path);
// `?edition=` is now REQUIRED (#1712) — every existing call in this file
// exercises `?class=`/`?maxLevel=`/membership behavior, not the edition gate
// itself, so this helper appends a default edition rather than touching every
// call site. The dedicated 400/fork describe blocks below call supertest
// directly (or pass an explicit edition) where the param IS the thing under test.
function get(path: string, edition: string = "EDITION_2024") {
const sep = path.includes("?") ? "&" : "?";
return supertest.agent(app).set("Cookie", COOKIE).get(`${path}${sep}edition=${edition}`);
}

function names(body: { name: string }[]): string[] {
Expand Down Expand Up @@ -234,3 +240,113 @@ describe("GET /api/spells — class membership served from the SpellClass join (
expect(await prisma.spellClass.findMany({ where: { spellId: damage.id } })).toEqual([]);
});
});

// #1712: `?edition=` is now REQUIRED — reverses #1377's "no ?edition=" (the
// docstring this route carried at spells.ts:18 before this slice). Absent and
// unrecognized both 400, matching featsRouter/referenceRouter's precedent
// (#1411/#1412) exactly, including the two distinct messages.
describe("GET /api/spells — ?edition= is required (#1712)", () => {
it("400s with no ?edition= at all, rather than serving a flat cross-edition catalog", async () => {
const res = await supertest(app).get("/api/spells").set("Cookie", COOKIE);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Missing required query parameter: edition");
});

it("400s an unrecognized ?edition= value, with a message distinct from the missing-param one", async () => {
const res = await supertest(app).get("/api/spells?edition=bogus").set("Cookie", COOKIE);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^Unknown edition: /);
});

it("400s for edition even when class/maxLevel are also present", async () => {
const res = await supertest(app).get("/api/spells?class=wizard&maxLevel=3").set("Cookie", COOKIE);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Missing required query parameter: edition");
});
});

// #1712: the real plumbing proof — a GENUINE 2014/2024 fork (same name, two
// rows) must resolve to exactly ONE row per requesting edition. A lone
// single-edition-tagged row with NO sibling — today's entire real ~109-row
// catalog, all EDITION_2024 with no 2014 counterpart (2014 content slices
// #1713-#1721 haven't landed) — is graceful instead: served to BOTH editions
// until a real sibling exists (resolveSpellCatalogForEdition's own comment
// has the full reasoning; a stricter "exclude on bare tag mismatch" version
// emptied the 2014 creation picker and broke creation.spec.ts's 2014 Warlock
// e2e test, which documents spells as edition-invariant today by design).
describe("GET /api/spells — genuine edition fork resolves to one row per edition (#1712)", () => {
const FORK_NAME = "Test Fork Catalog Spell";
const LONE_2024_NAME = "Test Lone 2024-Tagged Catalog Spell";

function forkRow(name: string, description: string) {
return {
name,
level: 1,
school: "evocation" as const,
castingTime: "1 action",
range: "30 feet",
duration: "Instantaneous",
description,
concentration: false,
ritual: false,
cantripScaling: false,
};
}

afterAll(async () => {
await prisma.spell.deleteMany({
where: { name: { in: [FORK_NAME, LONE_2024_NAME] } },
});
});

it("a name with both a 2014 and a 2024 row resolves to exactly the requesting edition's own row", async () => {
const row2014 = forkRow(FORK_NAME, "The PHB'14 text.");
const row2024 = forkRow(FORK_NAME, "The SRD 5.2 text.");
const fork2014 = await upsertEditionRow(prisma.spell, { name: FORK_NAME, edition: "EDITION_2014" }, { ...row2014, edition: "EDITION_2014" }, row2014);
const fork2024 = await upsertEditionRow(prisma.spell, { name: FORK_NAME, edition: "EDITION_2024" }, { ...row2024, edition: "EDITION_2024" }, row2024);

const res2014 = await get("/api/spells", "EDITION_2014");
const matches2014 = res2014.body.filter((s: { name: string }) => s.name === FORK_NAME);
expect(matches2014).toHaveLength(1);
expect(matches2014[0].id).toBe(fork2014.id);
expect(matches2014[0].description).toBe("The PHB'14 text.");

const res2024 = await get("/api/spells", "EDITION_2024");
const matches2024 = res2024.body.filter((s: { name: string }) => s.name === FORK_NAME);
expect(matches2024).toHaveLength(1);
expect(matches2024[0].id).toBe(fork2024.id);
expect(matches2024[0].description).toBe("The SRD 5.2 text.");
});

it("once a 2014 sibling exists, the 2024 row STOPS leaking into the 2014 response (proof the graceful fallback yields to a real fork)", async () => {
const row2024 = forkRow(FORK_NAME, "The SRD 5.2 text.");
await upsertEditionRow(prisma.spell, { name: FORK_NAME, edition: "EDITION_2024" }, { ...row2024, edition: "EDITION_2024" }, row2024);

// Before the 2014 sibling exists: graceful fallback serves the lone 2024
// row to a 2014 request too (today's real-catalog behavior).
const before = await get("/api/spells", "EDITION_2014");
expect(names(before.body).filter((n: string) => n === FORK_NAME)).toHaveLength(1);

// Once the sibling lands, the fork becomes genuine and exact-match wins —
// the 2024 row no longer reaches a 2014 request.
const row2014 = forkRow(FORK_NAME, "The PHB'14 text.");
const fork2014 = await upsertEditionRow(prisma.spell, { name: FORK_NAME, edition: "EDITION_2014" }, { ...row2014, edition: "EDITION_2014" }, row2014);
const after = await get("/api/spells", "EDITION_2014");
const matches = after.body.filter((s: { name: string }) => s.name === FORK_NAME);
expect(matches).toHaveLength(1);
expect(matches[0].id).toBe(fork2014.id);
});

it("a lone EDITION_2024-tagged row with no sibling is served to a 2014 request too (graceful — matches today's real catalog)", async () => {
const row2024 = forkRow(LONE_2024_NAME, "Ordinary 2024-tagged content, no 2014 fork yet.");
const lone = await upsertEditionRow(prisma.spell, { name: LONE_2024_NAME, edition: "EDITION_2024" }, { ...row2024, edition: "EDITION_2024" }, row2024);

const res2014 = await get("/api/spells", "EDITION_2014");
const matches2014 = res2014.body.filter((s: { name: string }) => s.name === LONE_2024_NAME);
expect(matches2014).toHaveLength(1);
expect(matches2014[0].id).toBe(lone.id);

const res2024 = await get("/api/spells", "EDITION_2024");
expect(names(res2024.body)).toContain(LONE_2024_NAME);
});
});
Loading
Loading