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
24 changes: 13 additions & 11 deletions backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -174,18 +176,18 @@ 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,
buffModifier: null,
};
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
Expand All @@ -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 } });
Expand Down
60 changes: 55 additions & 5 deletions backend/prisma/seed/__tests__/granted-ability-fork-reseed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand Down Expand Up @@ -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 }),
Expand All @@ -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 },
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions backend/prisma/seed/__tests__/monk-2024-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
19 changes: 12 additions & 7 deletions backend/prisma/seed/__tests__/seed-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading