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,2 @@
-- AlterEnum
ALTER TYPE "CharacterEventType" ADD VALUE 'castDiscipline';
1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,7 @@ enum CharacterEventType {
learnSubclassChoice
forgetSubclassChoice
subclassChoicesReconciled
castDiscipline
// advancement (ASI + feats)
abilityScoreImprovement
featTaken
Expand Down
57 changes: 57 additions & 0 deletions backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CLASSES, BACKGROUNDS, ITEMS, type CatalogItem } from "./seed/catalog-da
import { ACTIONS } from "./seed/actions.js";
import { MANEUVERS } from "./seed/maneuvers.js";
import { SHADOW_ARTS } from "./seed/shadow-arts.js";
import { DISCIPLINES } from "./seed/disciplines.js";
import { CHANNEL_DIVINITIES } from "./seed/channel-divinity.js";
import { SUBCLASS_CHOICE_OPTIONS } from "./seed/subclass-choices.js";
import { FEATS } from "./seed/feats.js";
Expand Down Expand Up @@ -204,6 +205,60 @@ async function seedShadowArts(prisma: PrismaClient) {
await prisma.grantedAbility.deleteMany({ where: staleWhere });
}

// Seed the Way of the Four Elements discipline catalog (2014-only, #1503) —
// upsert by (name, edition). Unlike seedSubclassChoiceOptions below, each row
// carries its own cost (Ki, "pool") and, where the discipline deals damage, an
// EffectSpec — the cast handler (lib/classes/disciplines.ts) reads both.
async function seedDisciplines(prisma: PrismaClient) {
for (const discipline of DISCIPLINES) {
const data = {
name: discipline.name,
edition: discipline.edition,
source: "discipline",
description: discipline.description,
minLevel: discipline.minLevel,
alwaysKnown: orElse(discipline.alwaysKnown, false),
costKind: discipline.costKind,
costPoolKey: orNull(discipline.costPoolKey),
costBase: orNull(discipline.costBase),
costPerStep: orNull(discipline.costPerStep),
effectKind: orNull(discipline.effectKind),
effectDiceCount: orNull(discipline.effectDiceCount),
effectDiceFaces: orNull(discipline.effectDiceFaces),
damageType: orNull(discipline.damageType),
attackType: orNull(discipline.attackType),
saveAbility: orNull(discipline.saveAbility),
saveEffect: orNull(discipline.saveEffect),
};
await upsertEditionRow(
prisma.grantedAbility,
{ name: discipline.name, edition: discipline.edition },
data,
data,
);
}
// Every seeded row is EDITION_2014 (Way of the Four Elements has no 2024
// counterpart — 2024's Warrior of the Elements is a from-scratch rebuild,
// not a discipline menu), so the NULL and EDITION_2024 partitions both get
// `notIn: []`, matching every `source: "discipline"` row in them —
// deliberately: this is what sweeps the 17 orphaned pre-#1373-retirement
// rows (edition: NULL, a stale *Fangs of the Fire Snake* etc. snapshot)
// still sitting in a long-lived dev database (#1503's own decision comment,
// 2026-08-03). The same NULL-partition-empties-to-notIn-everything shape is
// a DATA-LOSS bug the other direction — see prune.ts's own header — but
// here, with an all-EDITION_2014 seeded list, it is the intended cleanup.
const staleWhere = staleCatalogRowsWhere(
"name",
DISCIPLINES.map((d) => ({ identity: d.name, edition: d.edition })),
{ source: "discipline" },
);
const stale = await prisma.grantedAbility.findMany({ where: staleWhere, select: { name: true, edition: true } });
if (stale.length) {
console.log(`seedDisciplines: dropping stale catalog rows: ${stale.map((d) => `${d.name} (${d.edition ?? "shared"})`).join(", ")}`);
}
await prisma.grantedAbility.deleteMany({ where: staleWhere });
}

// Seed generic subclass "choose N" options (#899) as GrantedAbility rows keyed
// by `source` = the choice's catalogSource. Plain descriptive features — no
// cost/effect columns.
Expand Down Expand Up @@ -469,6 +524,7 @@ async function main() {
...SHADOW_ARTS,
...CHANNEL_DIVINITIES,
...SUBCLASS_CHOICE_OPTIONS,
...DISCIPLINES,
]);
await seedSpecies(prisma);
// #1682: trait content, resolved against the Species/SpeciesVariant rows
Expand All @@ -481,6 +537,7 @@ async function main() {
await seedActions(prisma);
await seedManeuvers(prisma);
await seedShadowArts(prisma);
await seedDisciplines(prisma);
await seedChannelDivinities(prisma);
await seedSubclassChoiceOptions(prisma);
await seedFeats(prisma);
Expand Down
97 changes: 97 additions & 0 deletions backend/prisma/seed/__tests__/discipline-fork-reseed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Proves seedDisciplines' shape (#1503): upsertEditionRow run twice is
// idempotent (no duplicate rows, no drift), and staleCatalogRowsWhere with an
// all-EDITION_2014 seeded list sweeps the orphaned NULL-edition rows the
// pre-retirement disciplines engine (34f5a4cf) left behind in a long-lived
// dev database — same idiom as granted-ability-fork-reseed.test.ts
// (seedDisciplines is inline in seed.ts, which self-invokes main() at module
// load and exports nothing a test can re-run directly, per that file's own
// header).
import { afterEach, describe, expect, it } from "vitest";

import { prisma } from "@/lib/core/prisma.js";
import { upsertEditionRow } from "@/lib/rules/catalog-edition.js";

import { staleCatalogRowsWhere } from "../prune.js";

const DISCIPLINE_NAME = "Zzz Fork Reseed Discipline (#1503)";
const ORPHAN_NAME = "Zzz Fork Reseed Discipline Orphan (#1503)";

afterEach(async () => {
await prisma.grantedAbility.deleteMany({ where: { name: { in: [DISCIPLINE_NAME, ORPHAN_NAME] } } });
});

describe("seedDisciplines' upsert is idempotent (#1503)", () => {
it("running upsertEditionRow twice for the same (name, EDITION_2014) row updates in place, no duplicate", async () => {
const data = {
name: DISCIPLINE_NAME,
source: "discipline",
edition: "EDITION_2014" as const,
description: "v1",
minLevel: 3,
alwaysKnown: false,
costKind: "pool",
costPoolKey: "ki",
costBase: 1,
};
let last;
for (let run = 0; run < 2; run += 1) {
last = await upsertEditionRow(
prisma.grantedAbility,
{ name: DISCIPLINE_NAME, edition: "EDITION_2014" },
{ ...data, description: run === 0 ? "v1" : "v2" },
{ description: run === 0 ? "v1" : "v2" },
);
}
const rows = await prisma.grantedAbility.findMany({ where: { name: DISCIPLINE_NAME } });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(last!.id);
expect(rows[0].description).toBe("v2");
expect(rows[0].edition).toBe("EDITION_2014");
});
});

describe("seedDisciplines' prune sweeps orphaned NULL-edition rows (#1503's own decision, 2026-08-03)", () => {
it("an all-EDITION_2014 seeded list drops a NULL-edition row of the same name (the 17 pre-retirement orphans)", async () => {
await prisma.grantedAbility.create({
data: { name: ORPHAN_NAME, source: "discipline", description: "pre-retirement orphan", edition: null },
});
const retagged = await upsertEditionRow(
prisma.grantedAbility,
{ name: ORPHAN_NAME, edition: "EDITION_2014" },
{ name: ORPHAN_NAME, source: "discipline", description: "current", edition: "EDITION_2014", minLevel: 3, alwaysKnown: false },
{ description: "current" },
);

// Exactly what seedDisciplines passes: every DISCIPLINES row's OWN
// (always EDITION_2014) edition — never a flat null. Scoped to this
// file's own fixture name too (name: {in: [ORPHAN_NAME]}), same as
// granted-ability-fork-reseed.test.ts's ONLY_THIS_FILES_ROWS — without it
// this delete matches every OTHER source:"discipline" row not in this
// test's tiny seeded list too, i.e. the real 16-row catalog.
const seededAllEdition2014 = [{ identity: ORPHAN_NAME, edition: "EDITION_2014" as const }];
const staleWhere = staleCatalogRowsWhere("name", seededAllEdition2014, {
source: "discipline",
name: { in: [ORPHAN_NAME] },
});
await prisma.grantedAbility.deleteMany({ where: staleWhere });

const surviving = await prisma.grantedAbility.findMany({ where: { name: ORPHAN_NAME } });
expect(surviving).toHaveLength(1);
expect(surviving[0].id).toBe(retagged.id);
expect(surviving[0].edition).toBe("EDITION_2014");
});
});

describe("integration: the real seeded discipline catalog (#1503)", () => {
it("has exactly 16 source:\"discipline\" rows, all EDITION_2014, zero edition:NULL", async () => {
const rows = await prisma.grantedAbility.findMany({ where: { source: "discipline" } });
expect(rows).toHaveLength(16);
expect(rows.every((r) => r.edition === "EDITION_2014")).toBe(true);
expect(rows.some((r) => r.edition === null)).toBe(false);
});

it("Elemental Attunement is NOT in the catalog (it's a DerivedFeature, not a pickable option)", async () => {
const row = await prisma.grantedAbility.findFirst({ where: { source: "discipline", name: "Elemental Attunement" } });
expect(row).toBeNull();
});
});
59 changes: 59 additions & 0 deletions backend/prisma/seed/__tests__/disciplines-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Content-correctness audit for the discipline catalog (#1503 review fix):
// PHB'14 p.81 every save-based damage discipline explicitly halves damage on
// a successful save ("takes half damage on a success" / "half on a
// success") — Fist of Unbroken Air and Water Whip's own DESCRIPTIONS already
// said so, but their seed rows omitted `saveEffect: "half"`, so
// catalogEffectSpec (lib/combat/effects.ts) resolved `saveEffect: null` and a
// successful save would have wrongly dealt FULL damage. Asserted as a
// general invariant (not just the two named rows) so the same field/text
// mismatch can't reappear on a future discipline.
//
// Lives under prisma/seed/__tests__ (not backend/src/**) because it imports
// DISCIPLINES directly — backend/tsconfig.json's `rootDir: "src"` makes a
// src file importing anything under prisma/ a compile error (TS6059), the
// same constraint literal-fixture-parity.test.ts's own header documents.
import { describe, expect, it } from "vitest";

import { DISCIPLINES } from "../disciplines.js";

describe("discipline catalog content audit (#1503 review fix)", () => {
it("every save-based damage discipline resolves saveEffect \"half\" (PHB'14 p.81: all are save-for-half)", () => {
const saveForDamage = DISCIPLINES.filter((d) => d.effectKind === "damage" && d.attackType === "save");
// Anti-vacuity: today's catalog has 8 such rows (Fist of Four Thunders,
// Fist of Unbroken Air, Sweeping Cinder Strike, Water Whip, Gong of the
// Summit, Flames of the Phoenix, Breath of Winter, River of Hungry
// Flame) — a filter that stopped matching any of them would make this
// test pass by iterating nothing.
expect(saveForDamage.length).toBeGreaterThanOrEqual(8);
const missing = saveForDamage.filter((d) => d.saveEffect !== "half").map((d) => d.name);
expect(missing, `save-for-damage discipline(s) missing saveEffect "half": ${missing.join(", ")}`).toEqual([]);
});

it("Fist of Unbroken Air and Water Whip specifically carry saveEffect \"half\" (the two rows this review caught)", () => {
for (const name of ["Fist of Unbroken Air", "Water Whip"]) {
const row = DISCIPLINES.find((d) => d.name === name);
expect(row, name).toBeDefined();
expect(row!.saveEffect, name).toBe("half");
}
});

it("Fangs of the Fire Snake (the one attack-roll, not save, damage discipline) carries no saveEffect", () => {
const row = DISCIPLINES.find((d) => d.name === "Fangs of the Fire Snake")!;
expect(row.attackType).toBe("attack");
expect(row.saveEffect).toBeUndefined();
});

it("every damageType/effectDiceCount/effectDiceFaces/saveAbility field is present exactly when effectKind is \"damage\"", () => {
for (const d of DISCIPLINES) {
if (d.effectKind === "damage") {
expect(d.effectDiceCount, d.name).toBeGreaterThan(0);
expect(d.effectDiceFaces, d.name).toBeGreaterThan(0);
expect(d.damageType, d.name).toBeTruthy();
} else {
expect(d.effectDiceCount, d.name).toBeUndefined();
expect(d.effectDiceFaces, d.name).toBeUndefined();
expect(d.damageType, d.name).toBeUndefined();
}
}
});
});
Loading
Loading