Files
Dimension-47/.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md

52 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
01-level-up-pf2e-regelkonform 03 execute 2
01-01
01-02
server/prisma/seed-class-progression.ts
server/prisma/data/spell-slot-overlays.ts
server/prisma/data/class-feature-options.ts
server/prisma/data/foundry-pf2e/.keep
.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
true
LVL-08
LVL-14
LVL-19
seeding
prisma
foundry-pf2e
class-progression
spellcaster
level-up
truths artifacts key_links
After running `npm run db:seed:class-progression`, the ClassProgression table contains at least 320 rows (16 classes × 20 levels).
After seed, every caster class (Wizard, Sorcerer, Bard, Cleric, Druid, Oracle, Witch) has spellSlotIncrement and/or cantripIncrement entries at appropriate levels.
After seed, classes with L1 doctrine/school/etc. choices (Cleric L1, Wizard L1, Champion L1) have rows with `choiceType` and `choiceOptionsRef` populated, and the matching ClassFeatureOption rows exist.
Seed script is idempotent — running it twice does not duplicate rows.
Seed script reads from Foundry pf2e clone at `server/prisma/data/foundry-pf2e/` (gitignored, dev-time clone) and merges in the hand-curated `spell-slot-overlays.ts` constant.
If the Foundry clone is missing, the script fails loudly with a documented error message pointing the dev to the seed README.
path provides exports
server/prisma/seed-class-progression.ts Idempotent seed transforming Foundry pf2e class JSONs + spell-slot overlay → ClassProgression + ClassFeatureOption rows
main (executed when run via tsx)
path provides contains
server/prisma/data/spell-slot-overlays.ts Hand-curated spell-slot/cantrip/repertoire progression for the 16 D-16 classes that cast SPELL_SLOT_OVERLAY
path provides contains
server/prisma/data/class-feature-options.ts Hand-curated ClassFeatureOption seed data — Cleric Doctrines, Wizard Schools, Champion Causes, etc. CLASS_FEATURE_OPTIONS
path provides
server/prisma/data/foundry-pf2e/.keep Anchor file so the gitignored data dir exists in fresh clones for the README to reference
path provides contains
.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md Developer instructions: how to clone Foundry pf2e at the pinned tag and run the seed git clone
from to via pattern
seed-class-progression.ts ClassProgression table prisma.classProgression.create / update prisma.classProgression.
from to via pattern
seed-class-progression.ts ClassFeatureOption table prisma.classFeatureOption.create / update prisma.classFeatureOption.
from to via pattern
seed-class-progression.ts spell-slot-overlays.ts import SPELL_SLOT_OVERLAY from ['"].*spell-slot-overlays['"]
from to via pattern
seed-class-progression.ts Foundry pf2e clone fs.readFileSync of packs/classes/*.json foundry-pf2e.*packs/classes
Build the Foundry-pf2e-driven seed pipeline that populates the `ClassProgression` and `ClassFeatureOption` tables for the 16 in-scope Core+APG classes (D-16) across levels 1..20. The seed transforms Foundry pf2e class JSON files (manually cloned by the dev to `server/prisma/data/foundry-pf2e/`) into our schema and merges in hand-curated overlays for spell-slot progressions (which Foundry encodes as prose, not machine-readable rules — Pitfall #6) and class-feature options (Cleric Doctrines, Wizard Schools, etc.).

Purpose: Plan 04's atomic commit transaction needs ClassProgression rows to know what each (className, level) grants and what proficiency/spell-slot/repertoire changes apply. Without this seed, the LevelingService cannot recompute correctly. The seed is idempotent so devs and CI can re-run safely.

Output: Seed script + two hand-curated data modules + .keep anchor + dev README with cloning instructions.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/REQUIREMENTS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md @.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md @.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md @.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md @server/prisma/seed-equipment.ts @server/prisma/seed.ts ```jsonc { "_id": "...", "name": "Fighter", "type": "class", "system": { "hp": 10, "perception": 2, "keyAbility": ["dex", "str"], "spellcasting": 0, "attacks": { "simple": 2, "martial": 2, "advanced": 1, "unarmed": 2 }, "defenses": { "light": 1, "medium": 1, "heavy": 1, "unarmored": 1 }, "savingThrows": { "fortitude": 2, "reflex": 2, "will": 1 }, "trainedSkills": { "value": [], "additional": 3 }, "classFeatLevels": { "value": [1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20] }, "ancestryFeatLevels":{ "value": [1, 5, 9, 13, 17] }, "skillFeatLevels": { "value": [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] }, "generalFeatLevels": { "value": [3, 7, 11, 15, 19] }, "items": { "u8k07": { "img": "...", "level": 5, "name": "Fighter Weapon Mastery", "uuid": "Compendium.pf2e.classfeatures.Item.Fighter Weapon Mastery" } // … } } } ```
import 'dotenv/config';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
for (const item of data) {
  const existing = await prisma.x.findUnique({ where: { unique_combo } });
  if (existing) {
    await prisma.x.update({ where: { id: existing.id }, data: { ... } });
    updated++;
  } else {
    await prisma.x.create({ data: { ... } });
    created++;
  }
}
export const SPELL_SLOT_OVERLAY: Record<string, Array<{
  level: number;
  spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
  cantripIncrement?: number;
  repertoireIncrement?: number;
}>> = { Wizard: [...], Sorcerer: [...], ... };
Task 1: Create the dev README + .keep anchor for the Foundry pf2e clone path server/prisma/data/foundry-pf2e/.keep, .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 648-654 — Pitfall 5: Foundry data shape; lines 285-289 — Project Structure) - .gitignore (must already contain `server/prisma/data/foundry-pf2e/` from Plan 01 Task 3 Step 5) **Step 1 — Create `server/prisma/data/foundry-pf2e/.keep`** as an empty file (so the directory itself can be tracked even though its contents are gitignored — this lets the README reference an existing path).
Note: `.gitignore` already excludes `server/prisma/data/foundry-pf2e/` per Plan 01. To track the `.keep` file, append an exception in `.gitignore` BEFORE this task — actually, the cleaner approach: do NOT track the directory at all. Instead, the seed script's missing-clone error message tells the dev to create it. Skip the .keep file entirely.

REVISED Step 1: Skip the .keep file. The directory will be created by the dev when they run `git clone`. This avoids the .gitignore exception entirely.

**Step 2 — Create `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md`** with the following content:

```markdown
# Phase 1 — ClassProgression Seed README

The seed script `server/prisma/seed-class-progression.ts` populates the `ClassProgression`
and `ClassFeatureOption` tables from the Foundry pf2e system repository (license: Apache 2.0
+ OGL 1.0a + Paizo Community Use Policy — verified 2026-04-27).

## One-time dev setup

Clone the pinned Foundry pf2e tag into the gitignored data folder:

```bash
cd server/prisma/data
git clone --depth 1 --branch <PINNED_TAG> https://github.com/foundryvtt/pf2e.git foundry-pf2e
```

**Pinned tag:** `<TAG_DECIDED_BY_EXECUTOR>` (record the chosen tag here when this task runs;
the executor selects a stable release branch from `https://github.com/foundryvtt/pf2e/tags`
that includes Core Rulebook + APG content for all 16 D-16 classes).

## Run the seed

```bash
cd server
npm run db:seed:class-progression
```

Idempotent: running twice does not duplicate rows. Console output reports `created` and
`updated` counts plus any errors.

## Failure modes

- **"Foundry pf2e clone not found at server/prisma/data/foundry-pf2e/packs/classes/"** —
  run the `git clone` step above.
- **"Class JSON does not match expected schema"** — Foundry pf2e changed the JSON shape
  between major versions. Update the seed parser or pin to an older tag.

## What gets seeded

- **ClassProgression** rows: 16 classes × 20 levels = 320 rows, with `grants[]`,
  `proficiencyChanges`, `spellSlotIncrement`, `cantripIncrement`, `repertoireIncrement`,
  `choiceType`, `choiceOptionsRef`.
- **ClassFeatureOption** rows: hand-curated Cleric Doctrines, Wizard Schools, Champion
  Causes, Sorcerer Bloodlines (where L1-set), Druid Orders, etc.
- Spell-slot/cantrip/repertoire progressions come from the hand-curated overlay
  `server/prisma/data/spell-slot-overlays.ts` because Foundry encodes them in prose
  (Pitfall #6).
```

Note: The `<PINNED_TAG>` placeholder is filled in during execution when the dev/executor selects a tag.

**Step 3 — Verify:** `ls server/prisma/data/` should not yet contain `foundry-pf2e/` (the dev creates it later via the README instructions). The README itself lives under `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md`.
test -f .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md && grep -c "git clone" .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md - File `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` exists - File contains the literal string `git clone` - File contains the literal string `npm run db:seed:class-progression` - File contains the literal string `Apache 2.0` (license declaration) - File contains a "Pinned tag" reference (placeholder OK if dev hasn't picked one yet) Dev README documents the clone step and the run step. No tracked files inside the gitignored data dir. Task 2: Hand-curated spell-slot overlay (16 classes, all levels) server/prisma/data/spell-slot-overlays.ts - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 656-661 — Pitfall #6 + lines 897-924 — Code Examples #6 shape) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-16 — class scope; D-17 — Foundry as source; D-18 — spontaneous casters get repertoire) - server/src/modules/leveling/lib/types.ts (Proficiency, AbilityAbbreviation type vocabulary; SpellTradition can be defined here in the overlay file) - Archives of Nethys spell tables (cited as authoritative source for hand-curation): https://2e.aonprd.com — for each caster class, the canonical PF2e Player Core / APG spell-slot table Create `server/prisma/data/spell-slot-overlays.ts` containing hand-curated spell-slot/cantrip/repertoire data for all 16 D-16 classes. Casters get rows; non-casters (Fighter, Barbarian, Rogue, Monk, Swashbuckler, Investigator, Ranger, Alchemist) get an empty array.
**File header:**

```typescript
/**
 * Hand-curated spell-slot / cantrip / repertoire progression overlay.
 *
 * WHY HAND-CURATED: Foundry pf2e encodes slot tables in description prose, not machine-
 * readable rules (Pitfall #6 / verified 2026-04-27 against `wizard-spellcasting.json`).
 * NLP-parsing prose is fragile; the canonical PF2e tables fit in ~300 lines and are stable
 * across reprints.
 *
 * SOURCE: Archives of Nethys — Pathfinder 2e Player Core + Advanced Player's Guide
 * (https://2e.aonprd.com). Per-class spell-slot tables, cantrip counts, and repertoire
 * sizes (spontaneous casters only).
 *
 * SCOPE: 16 D-16 classes (Core + APG). Casters: Bard, Cleric, Druid, Oracle, Sorcerer,
 * Witch, Wizard, Champion (focus only — minimal). Non-casters: empty array.
 *
 * SPONTANEOUS vs PREPARED: Spontaneous casters (Bard, Sorcerer, Oracle) get
 * repertoireIncrement entries on level-up. Prepared casters (Cleric, Druid, Witch, Wizard)
 * get spellSlotIncrement only. Both get cantripIncrement at L1.
 */

export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';

export interface SpellSlotOverlayEntry {
  level: number;
  spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
  cantripIncrement?: number;
  repertoireIncrement?: number;
}

/**
 * Each class maps to an array of overlay entries. Multiple entries per level are allowed
 * (e.g. L1 Wizard gets 5 cantrips AND 2 grade-1 slots — two separate entries).
 * Order within a level does not matter — the seed script merges them per (class, level).
 */
export const SPELL_SLOT_OVERLAY: Record<string, SpellSlotOverlayEntry[]> = {
  // === PREPARED CASTERS ===

  Wizard: [
    { level: 1, cantripIncrement: 5 },
    { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
    { level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
    { level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
    { level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } },
    { level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 2 } },
    { level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } },
    { level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 2 } },
    { level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } },
    { level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 2 } },
    { level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } },
    { level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 2 } },
    { level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } },
    { level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 2 } },
    { level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } },
    { level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 2 } },
    { level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } },
    { level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 2 } },
    { level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } },
    // L19 Magnum Opus = 1 grade-10 slot per day
    { level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } },
    // L20: no slot increment (capstone is qualitative)
  ],

  Cleric: [
    { level: 1, cantripIncrement: 5 },
    { level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 2 } },
    // … L2..L20 mirroring Wizard cadence with DIVINE tradition (executor curates from
    //   Archives of Nethys Cleric class entry).
  ],

  Druid: [
    { level: 1, cantripIncrement: 5 },
    { level: 1, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 1, count: 2 } },
    // … executor curates from AoN Druid entry.
  ],

  Witch: [
    { level: 1, cantripIncrement: 5 },
    // Witch tradition depends on patron — DEFAULT to ARCANE for the overlay; recompute
    //   may need the actual chosen patron for accurate filtering. Document in the
    //   seed README's "Caveats" section.
    { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
    // … executor curates from AoN Witch entry.
  ],

  // === SPONTANEOUS CASTERS (D-18 — get repertoireIncrement) ===

  Bard: [
    { level: 1, cantripIncrement: 5 },
    { level: 1, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 2 } },
    // Repertoire size at L1 = 4 (Player Core); growth = +1 per even level for spell-level
    //   slots gained. Executor curates exact deltas.
    { level: 2, repertoireIncrement: 1 },
    // … executor curates from AoN Bard entry, full L1..L20.
  ],

  Sorcerer: [
    { level: 1, cantripIncrement: 5 },
    // Tradition depends on bloodline — DEFAULT to ARCANE; document caveat.
    { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 3 } },
    { level: 2, repertoireIncrement: 1 },
    // … executor curates from AoN Sorcerer entry, full L1..L20.
  ],

  Oracle: [
    { level: 1, cantripIncrement: 5 },
    { level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 3 } },
    { level: 2, repertoireIncrement: 1 },
    // … executor curates from AoN Oracle entry, full L1..L20.
  ],

  // === HALF-CASTERS / FOCUS-ONLY (minimal entries) ===

  Champion: [
    // Champion gets focus spells (Devotion Spells) — for Phase 1 we record cantrip-like
    // L1 entry only; focus-spell mechanics handled outside the slot table.
    { level: 1, cantripIncrement: 0 },
  ],

  // === NON-CASTERS — explicitly empty so the seed knows "no overlay needed" ===

  Alchemist: [],
  Barbarian: [],
  Fighter: [],
  Investigator: [],
  Monk: [],
  Ranger: [],
  Rogue: [],
  Swashbuckler: [],
};
```

**Executor's curation responsibility:**

For Wizard, the table above is fully populated as a worked example. For the other 6 casters (Cleric, Druid, Witch, Bard, Sorcerer, Oracle), the executor must curate the full L1..L20 entries from Archives of Nethys before this task is `done`. Use the Wizard cadence as the template; tradition and counts vary per class.

For non-casters (Alchemist, Barbarian, Fighter, Investigator, Monk, Ranger, Rogue, Swashbuckler) the empty array is correct.

Champion has minimal entries (focus-spell mechanics are outside the slot table).

**Constraint:** No `: any` types. Every entry strictly typed against `SpellSlotOverlayEntry`.

**Constraint:** This file is hand-edited human knowledge — no programmatic generation. It is small (≤500 lines) and stable across PF2e printings.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "spell-slot-overlays" || echo "tsc clean" - File `server/prisma/data/spell-slot-overlays.ts` exists - File exports `SPELL_SLOT_OVERLAY` - File exports `SpellTradition` type and `SpellSlotOverlayEntry` interface - All 16 D-16 class names appear as keys in the SPELL_SLOT_OVERLAY object (Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard) - Wizard array has at least 19 entries spanning L1..L19 (verifiable: count entries with `level:` keys) - Bard, Sorcerer, Oracle arrays each contain at least one `repertoireIncrement` entry (D-18) - File contains NO `: any` outside comments - `cd server && npx tsc --noEmit` exits 0 (no type errors involving this file) Spell-slot overlay populated for all 7 caster classes (Wizard fully, others curated by executor) and explicitly empty for non-casters. Type-safe. Imported by Task 4. Task 3: Hand-curated class-feature-options data (D-19 choices) server/prisma/data/class-feature-options.ts - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-19 — Wahl-Klassenmerkmale; D-15 — choiceOptionsRef; D-16 — class scope) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 802-813 — ClassFeatureOption schema with grants + proficiencyChanges; lines 1064-1067 — Open Question Q2 recommendation) - server/prisma/schema.prisma (post-Plan-01 — ClassFeatureOption model) - Archives of Nethys class entries for: Cleric (Doctrines), Wizard (Schools), Champion (Causes), Druid (Orders), Sorcerer (Bloodlines, abbreviated), Bard (Muses), Barbarian (Instincts), Monk (no L1 choice but L8 Mountain Stance etc. — defer to v2) Create `server/prisma/data/class-feature-options.ts` containing hand-curated ClassFeatureOption seed data for the L1 (and any other) choice points across the 16 D-16 classes.
**Scope for Phase 1 (executor curates from AoN):**
- **Cleric Doctrines** (L1) — Cloistered Cleric, Warpriest (and any others available in Player Core)
- **Wizard Schools** (L1) — Arcane School: Battle Magic, Civic Wizardry, Mentalism, Protean Form, Unified Magical Theory, Universalist (Player Core arcane schools)
- **Champion Causes** (L1) — Liberator (Good), Paladin (Good), Redeemer (Good), Antipaladin (Evil), Tyrant (Evil), Desecrator (Evil) — and any neutral or remaster-equivalent
- **Druid Orders** (L1) — Animal, Leaf, Storm, Wild, plus any in APG
- **Sorcerer Bloodlines** (L1) — Aberrant, Angelic, Demonic, Diabolic, Draconic, Elemental, Fey, Genie, Hag, Imperial, Nymph, Phoenix, Psychopomp, Shadow, Undead, plus APG additions
- **Bard Muses** (L1) — Enigma, Maestro, Polymath, plus APG (Warrior etc.)
- **Barbarian Instincts** (L1) — Animal, Dragon, Fury, Giant, Spirit, plus APG (Superstition etc.)
- **Witch Patrons** (L1) — Faith, Fervor, Mosquito Witch, Resentment, Silence, Spinner of Threads, Starless Shadow, Wilding, plus Curse-of-the-Hag-Eye etc.
- **Oracle Mysteries** (L1) — Battle, Bones, Cosmos, Flames, Life, Lore, Tempest, plus APG
- **Investigator Methodologies** (APG) — Empiricism, Forensic Medicine, Interrogation, Sensate, Stratagem (or whatever exists)
- **Monk Stances** etc. — DEFER to v2 (Monk's L1 choice is minimal; complex stances are L2+ feats handled via the Klassentalent step)
- **Ranger Edges** (APG) — Flurry, Outwit, Precision
- **Rogue Rackets** — Eldritch Trickster, Mastermind, Ruffian, Scoundrel, Thief
- **Swashbuckler Styles** — Battledancer, Braggart, Fencer, Gymnast, Wit
- **Alchemist Research Fields** — Bomber, Chirurgeon, Mutagenist, Toxicologist
- **Fighter** — no L1 choice (handled at L5 Weapon Mastery via the L5 ClassProgression `choiceType`)

**File structure:**

```typescript
/**
 * Hand-curated ClassFeatureOption seed data for D-19 wizard sub-steps.
 *
 * Each entry's `optionsRef` matches a `ClassProgression.choiceOptionsRef` so the wizard
 * can resolve choice → option list at runtime.
 *
 * `grants` and `proficiencyChanges` are option-level mechanical effects applied at commit
 * (per RESEARCH.md §Open Question Q2 — symmetric with ClassProgression so the recompute
 * pipeline is uniform).
 *
 * SOURCE: Archives of Nethys — PF2e Player Core + APG class entries.
 * SCOPE: 16 D-16 classes' L1 (and other) choice points.
 */

import type { Proficiency } from '../../src/modules/leveling/lib/types';

export interface ClassFeatureOptionEntry {
  optionsRef: string;            // matches ClassProgression.choiceOptionsRef
  optionKey: string;             // unique within optionsRef
  name: string;                  // English (German via TranslationsService at runtime)
  nameGerman?: string;           // optional pre-translated German name (Phase 1 may leave this undefined and let TranslationsService handle on-demand)
  description: string;           // English
  grants: string[];              // class-feature names this option awards (e.g. ["Cloistered Cleric", "Domain Spell"])
  proficiencyChanges?: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
}

export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [
  // === CLERIC DOCTRINE (optionsRef: 'cleric-doctrine') ===
  {
    optionsRef: 'cleric-doctrine',
    optionKey: 'cloistered-cleric',
    name: 'Cloistered Cleric',
    description: 'You have devoted your life to the study of religion and the casting of divine spells…',
    grants: ['Cloistered Cleric Doctrine'],
    // Doctrine bumps spell DC trained → expert at varying levels; encoded via ClassProgression at L1
  },
  {
    optionsRef: 'cleric-doctrine',
    optionKey: 'warpriest',
    name: 'Warpriest',
    description: 'You are a martial defender of your faith…',
    grants: ['Warpriest Doctrine'],
    // Warpriest at L1 grants martial weapon proficiency = trained
  },

  // === WIZARD SCHOOL (optionsRef: 'wizard-school') ===
  {
    optionsRef: 'wizard-school',
    optionKey: 'battle-magic',
    name: 'School of Battle Magic',
    description: 'You focus on offensive evocations and battlefield control…',
    grants: ['Battle Magic Curriculum'],
  },
  // … executor curates remaining 5 schools

  // === CHAMPION CAUSE ===
  // … executor curates 6 causes

  // === DRUID ORDER ===
  // … executor curates ~4 orders

  // === SORCERER BLOODLINE ===
  // … executor curates ~15 bloodlines

  // === BARD MUSE ===
  // … executor curates 3-4 muses

  // === BARBARIAN INSTINCT ===
  // … executor curates 5+ instincts

  // === WITCH PATRON ===
  // … executor curates patrons

  // === ORACLE MYSTERY ===
  // … executor curates mysteries

  // === INVESTIGATOR METHODOLOGY ===
  // … executor curates methodologies

  // === RANGER EDGE ===
  // … executor curates edges

  // === ROGUE RACKET ===
  // … executor curates 5 rackets

  // === SWASHBUCKLER STYLE ===
  // … executor curates 5 styles

  // === ALCHEMIST RESEARCH FIELD ===
  // … executor curates 4 research fields (already in DB as ResearchField enum — cross-reference)
];
```

**Executor's curation responsibility:**

The Cleric Doctrine and Wizard School blocks are seeded above as worked examples. The executor must populate the remaining classes by reading each class's L1 entry on Archives of Nethys and producing a ClassFeatureOptionEntry object per option.

**Cross-references the executor must maintain:**
- `optionsRef` strings must match the `choiceOptionsRef` values used in `seed-class-progression.ts` (Task 4) when it sets `choiceType` and `choiceOptionsRef` on the L1 ClassProgression rows.
- `optionKey` values are stable identifiers — once chosen, do not rename (would break commits).
- `nameGerman` may be left undefined in this seed — the TranslationsService picks up English `name` and translates on demand (D-15).

**Constraint:** No `any` types. Type-checked.

**Constraint:** Alchemist research fields cross-reference the existing `ResearchField` enum in `server/prisma/schema.prisma` (BOMBER, CHIRURGEON, etc.) — the `optionKey` values for Alchemist must match the enum values lowercase-kebab so the existing `CharacterAlchemyState.researchField` column can be set from the option pick.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "class-feature-options" || echo "tsc clean" - File `server/prisma/data/class-feature-options.ts` exists - File exports `CLASS_FEATURE_OPTIONS` array - File exports `ClassFeatureOptionEntry` interface - Array contains at least 50 entries (cumulative across all classes — Cleric 2-4, Wizard 6, Champion 6, Druid 4, Sorcerer ≥10, Bard 3, Barbarian 5, Witch ≥5, Oracle ≥7, Investigator 3-5, Ranger 3, Rogue 5, Swashbuckler 5, Alchemist 4) - Every entry has a non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` field - At least 5 distinct `optionsRef` values appear (verifying all class choice points are represented) - File contains NO `: any` outside comments - `cd server && npx tsc --noEmit` exits 0 Class-feature options seeded for all D-16 classes that have L1 choice points. optionsRef strings established as the contract between this file and Task 4's seed script. Task 4: Seed script — Foundry pf2e + overlays → ClassProgression + ClassFeatureOption server/prisma/seed-class-progression.ts - server/prisma/seed-equipment.ts (canonical analog — imports, idempotent pattern, console output style) - server/prisma/data/spell-slot-overlays.ts (Task 2 output) - server/prisma/data/class-feature-options.ts (Task 3 output) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 686-725 — Foundry shape + mapping; lines 538-575 — seed pattern from PATTERNS.md analog) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 537-587 — exact seed pattern) - server/package.json (db:seed:class-progression script wired in Plan 01 Task 3) - server/prisma/schema.prisma (ClassProgression and ClassFeatureOption models) Create `server/prisma/seed-class-progression.ts` with the following structure (executor implements bodies):
```typescript
/**
 * Seed: ClassProgression + ClassFeatureOption from Foundry pf2e + hand-curated overlays.
 *
 * Sources:
 * - Foundry pf2e class JSONs at `server/prisma/data/foundry-pf2e/packs/classes/*.json`
 *   (cloned manually by dev; see SEED-README.md).
 * - SPELL_SLOT_OVERLAY from `data/spell-slot-overlays.ts` (Pitfall #6 mitigation).
 * - CLASS_FEATURE_OPTIONS from `data/class-feature-options.ts` (D-19 choices).
 *
 * Idempotent — safe to re-run.
 */

import 'dotenv/config';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
import { SPELL_SLOT_OVERLAY, type SpellSlotOverlayEntry } from './data/spell-slot-overlays';
import { CLASS_FEATURE_OPTIONS } from './data/class-feature-options';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });

const FOUNDRY_CLASSES_DIR = path.join(__dirname, 'data', 'foundry-pf2e', 'packs', 'classes');

// The 16 D-16 classes. Filename in Foundry = lowercase classname.
const D16_CLASS_NAMES = [
  'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid',
  'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue',
  'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard',
] as const;

// Map of (className → choiceType, choiceOptionsRef) for the L1 doctrine/school/etc. step.
// Executor populates from the optionsRef values defined in class-feature-options.ts.
const L1_CHOICE_MAP: Record<string, { choiceType: string; choiceOptionsRef: string } | null> = {
  Cleric: { choiceType: 'doctrine', choiceOptionsRef: 'cleric-doctrine' },
  Wizard: { choiceType: 'school', choiceOptionsRef: 'wizard-school' },
  Champion: { choiceType: 'cause', choiceOptionsRef: 'champion-cause' },
  Druid: { choiceType: 'order', choiceOptionsRef: 'druid-order' },
  Sorcerer: { choiceType: 'bloodline', choiceOptionsRef: 'sorcerer-bloodline' },
  Bard: { choiceType: 'muse', choiceOptionsRef: 'bard-muse' },
  Barbarian: { choiceType: 'instinct', choiceOptionsRef: 'barbarian-instinct' },
  Witch: { choiceType: 'patron', choiceOptionsRef: 'witch-patron' },
  Oracle: { choiceType: 'mystery', choiceOptionsRef: 'oracle-mystery' },
  Investigator: { choiceType: 'methodology', choiceOptionsRef: 'investigator-methodology' },
  Ranger: { choiceType: 'edge', choiceOptionsRef: 'ranger-edge' },
  Rogue: { choiceType: 'racket', choiceOptionsRef: 'rogue-racket' },
  Swashbuckler: { choiceType: 'style', choiceOptionsRef: 'swashbuckler-style' },
  Alchemist: { choiceType: 'research-field', choiceOptionsRef: 'alchemist-research-field' },
  Fighter: null,                  // Fighter has no L1 choice; weapon mastery is at L5
  Monk: null,                     // Monk's L1 stance is via class feats, not a choiceType
};

// Optional: Fighter L5 weapon-mastery group choice
const HIGHER_LEVEL_CHOICES: Array<{ className: string; level: number; choiceType: string; choiceOptionsRef: string }> = [
  { className: 'Fighter', level: 5, choiceType: 'weapon-mastery-group', choiceOptionsRef: 'fighter-weapon-mastery' },
];

interface FoundryClassJson {
  name: string;
  type: 'class';
  system: {
    hp: number;
    keyAbility: string[];
    spellcasting: number;
    savingThrows: { fortitude: number; reflex: number; will: number };
    attacks: Record<string, number | { rank: number; name?: string }>;
    defenses: Record<string, number>;
    classFeatLevels?: { value: number[] };
    items: Record<string, { level: number; name: string; uuid?: string }>;
  };
}

function readFoundryClass(className: string): FoundryClassJson {
  // Foundry filename convention: lowercase, e.g. 'fighter.json'
  const filename = `${className.toLowerCase()}.json`;
  const fullPath = path.join(FOUNDRY_CLASSES_DIR, filename);
  if (!fs.existsSync(fullPath)) {
    throw new Error(
      `Foundry pf2e clone not found at ${fullPath}. ` +
      `See .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md for setup.`,
    );
  }
  const raw = fs.readFileSync(fullPath, 'utf-8');
  const json = JSON.parse(raw) as FoundryClassJson;
  if (json.type !== 'class' || !json.name || !json.system) {
    throw new Error(`Class JSON does not match expected schema: ${fullPath}`);
  }
  return json;
}

/** Map Foundry numeric proficiency rank (0..8) to our Proficiency string enum. */
function profFromValue(v: number | undefined): 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY' {
  if (v === undefined || v === null) return 'UNTRAINED';
  if (v >= 8) return 'LEGENDARY';
  if (v >= 6) return 'MASTER';
  if (v >= 4) return 'EXPERT';
  if (v >= 2) return 'TRAINED';
  return 'UNTRAINED';
}

/**
 * Aggregate all Foundry items at this level and the spell-slot overlay entries at this level
 * into a single ClassProgression upsert payload.
 */
function buildProgressionRow(className: string, level: number, foundry: FoundryClassJson) {
  // 1. Grants from Foundry items
  const grants: string[] = Object.values(foundry.system.items)
    .filter(item => item.level === level)
    .map(item => item.name);

  // 2. Proficiency changes — for L1 only, take the class's base proficiency from the JSON.
  //    For L2+, executor adds hand-curated proficiency bumps as needed (e.g. Fighter L5
  //    martial → master). The Foundry data does NOT carry per-level proficiency bump
  //    info on the class JSON; that lives in classfeatures compendium files (Pitfall #6).
  //    For Phase 1 minimum: seed only L1 base proficiencies; populate higher-level bumps
  //    via a hand-curated overlay if time permits, otherwise leave proficiencyChanges
  //    empty and rely on the recompute pipeline's class-feature-driven lookups in v2.
  let proficiencyChanges: Record<string, string> = {};
  if (level === 1) {
    proficiencyChanges = {
      fortitude: profFromValue(foundry.system.savingThrows.fortitude),
      reflex: profFromValue(foundry.system.savingThrows.reflex),
      will: profFromValue(foundry.system.savingThrows.will),
    };
  }

  // 3. Spell-slot overlay merge
  const overlayEntries: SpellSlotOverlayEntry[] = (SPELL_SLOT_OVERLAY[className] || [])
    .filter(e => e.level === level);
  const slotEntry = overlayEntries.find(e => e.spellSlotIncrement);
  const cantripEntry = overlayEntries.find(e => e.cantripIncrement !== undefined);
  const repertoireEntry = overlayEntries.find(e => e.repertoireIncrement !== undefined);

  // 4. Choice type for this (class, level)
  let choiceType: string | null = null;
  let choiceOptionsRef: string | null = null;
  if (level === 1 && L1_CHOICE_MAP[className]) {
    const choice = L1_CHOICE_MAP[className]!;
    choiceType = choice.choiceType;
    choiceOptionsRef = choice.choiceOptionsRef;
  }
  const higherChoice = HIGHER_LEVEL_CHOICES.find(c => c.className === className && c.level === level);
  if (higherChoice) {
    choiceType = higherChoice.choiceType;
    choiceOptionsRef = higherChoice.choiceOptionsRef;
  }

  return {
    className,
    level,
    grants,
    proficiencyChanges,
    spellSlotIncrement: slotEntry?.spellSlotIncrement ?? null,
    cantripIncrement: cantripEntry?.cantripIncrement ?? null,
    repertoireIncrement: repertoireEntry?.repertoireIncrement ?? null,
    choiceType,
    choiceOptionsRef,
  };
}

async function seedClassProgression(): Promise<{ created: number; updated: number; errors: number }> {
  let created = 0; let updated = 0; let errors = 0;
  console.log('📚 Seeding ClassProgression for 16 classes × 20 levels…');

  for (const className of D16_CLASS_NAMES) {
    let foundry: FoundryClassJson;
    try {
      foundry = readFoundryClass(className);
    } catch (e) {
      errors++;
      console.error(`Failed to load Foundry class ${className}:`, (e as Error).message);
      continue;
    }
    for (let level = 1; level <= 20; level++) {
      const row = buildProgressionRow(className, level, foundry);
      try {
        const existing = await prisma.classProgression.findUnique({
          where: { className_level: { className, level } },
        });
        if (existing) {
          await prisma.classProgression.update({
            where: { id: existing.id },
            data: row,
          });
          updated++;
        } else {
          await prisma.classProgression.create({ data: row });
          created++;
        }
      } catch (e) {
        errors++;
        console.error(`Failed to seed ${className} L${level}:`, (e as Error).message);
      }
    }
  }
  console.log(`  ✓ ClassProgression: ${created} created, ${updated} updated, ${errors} errors`);
  return { created, updated, errors };
}

async function seedClassFeatureOptions(): Promise<{ created: number; updated: number; errors: number }> {
  let created = 0; let updated = 0; let errors = 0;
  console.log(`📚 Seeding ${CLASS_FEATURE_OPTIONS.length} ClassFeatureOption rows…`);

  for (const opt of CLASS_FEATURE_OPTIONS) {
    try {
      const existing = await prisma.classFeatureOption.findUnique({
        where: { optionsRef_optionKey: { optionsRef: opt.optionsRef, optionKey: opt.optionKey } },
      });
      if (existing) {
        await prisma.classFeatureOption.update({
          where: { id: existing.id },
          data: {
            name: opt.name,
            nameGerman: opt.nameGerman ?? null,
            description: opt.description,
            grants: opt.grants,
            proficiencyChanges: opt.proficiencyChanges ?? null,
          },
        });
        updated++;
      } else {
        await prisma.classFeatureOption.create({
          data: {
            optionsRef: opt.optionsRef,
            optionKey: opt.optionKey,
            name: opt.name,
            nameGerman: opt.nameGerman ?? null,
            description: opt.description,
            grants: opt.grants,
            proficiencyChanges: opt.proficiencyChanges ?? null,
          },
        });
        created++;
      }
    } catch (e) {
      errors++;
      console.error(`Failed to seed option ${opt.optionsRef}/${opt.optionKey}:`, (e as Error).message);
    }
  }
  console.log(`  ✓ ClassFeatureOption: ${created} created, ${updated} updated, ${errors} errors`);
  return { created, updated, errors };
}

async function main(): Promise<void> {
  const cp = await seedClassProgression();
  const cfo = await seedClassFeatureOptions();
  const totalErrors = cp.errors + cfo.errors;
  if (totalErrors > 0) {
    console.error(`\nSeed completed with ${totalErrors} errors.`);
    process.exit(1);
  }
  console.log('\n✅ ClassProgression + ClassFeatureOption seed complete.');
}

main()
  .catch(e => { console.error(e); process.exit(1); })
  .finally(() => prisma.$disconnect());
```

**Constraints:**
- `: any` is forbidden. Use the `FoundryClassJson` interface (or expand it).
- The script must fail loudly with the README pointer when the Foundry clone is missing.
- Idempotency: every row uses `findUnique → update OR create` per RESEARCH.md §Pitfall 5 + analog `seed-equipment.ts` lines 105-130.
- `prisma.classProgression.findUnique` uses the compound unique key `className_level` (Prisma generates this from `@@unique([className, level])`).
- `prisma.classFeatureOption.findUnique` uses `optionsRef_optionKey` (from `@@unique([optionsRef, optionKey])`).
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "seed-class-progression" || echo "tsc clean" - File `server/prisma/seed-class-progression.ts` exists - File contains the literal string `import { SPELL_SLOT_OVERLAY` (proves overlay import) - File contains the literal string `import { CLASS_FEATURE_OPTIONS` (proves option import) - File contains the literal string `prisma.classProgression.findUnique` (proves idempotent pattern) - File contains the literal string `prisma.classFeatureOption.findUnique` (proves idempotent pattern) - File contains the literal string `Foundry pf2e clone not found at` (proves loud-fail pattern) - File contains NO `: any` outside comments - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 Seed script type-checks cleanly. Idempotent CRUD against ClassProgression + ClassFeatureOption. Imports both hand-curated data modules. Task 5: Run the seed (manual verification — requires Foundry clone) (no file writes — execution + verification only) - .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md - server/prisma/seed-class-progression.ts - server/package.json (db:seed:class-progression script) **Step 1 — Clone the Foundry pf2e repo at a stable tag** following the README:
```bash
cd server/prisma/data
git clone --depth 1 --branch <CHOSEN_TAG> https://github.com/foundryvtt/pf2e.git foundry-pf2e
```

The executor selects a stable tag from `https://github.com/foundryvtt/pf2e/tags` that has been released ≥1 week ago and has no known issues. Record the tag in the SEED-README.md `<PINNED_TAG>` placeholder.

**Step 2 — Verify the clone has class JSONs:**

```bash
ls server/prisma/data/foundry-pf2e/packs/classes/
```

Should list at least 16 `.json` files matching the D-16 class names (lowercase).

**Step 3 — Run the seed:**

```bash
cd server && npm run db:seed:class-progression
```

Expected output (approximate):
```
📚 Seeding ClassProgression for 16 classes × 20 levels…
  ✓ ClassProgression: 320 created, 0 updated, 0 errors
📚 Seeding 60 ClassFeatureOption rows…
  ✓ ClassFeatureOption: 60 created, 0 updated, 0 errors
✅ ClassProgression + ClassFeatureOption seed complete.
```

**Step 4 — Run the seed AGAIN to prove idempotency:**

```bash
cd server && npm run db:seed:class-progression
```

Expected output: `0 created, 320 updated, 0 errors` for ClassProgression and `0 created, N updated, 0 errors` for ClassFeatureOption.

**Step 5 — Verify row counts:**

```bash
psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" GROUP BY "className" ORDER BY "className"'
```

Expected: 16 rows, each `count = 20`.

```bash
psql $DATABASE_URL -c 'SELECT "optionsRef", COUNT(*) FROM "ClassFeatureOption" GROUP BY "optionsRef" ORDER BY "optionsRef"'
```

Expected: at least 5 distinct optionsRef rows, each with ≥2 options.

**Step 6 — Verify spellcaster overlays applied:**

```bash
psql $DATABASE_URL -c 'SELECT "className", "level", "spellSlotIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" <= 5 ORDER BY "level"'
```

Expected: rows for Wizard L1..L5 with non-null `spellSlotIncrement` JSON containing `tradition: ARCANE`.

If any verification step fails, the plan is NOT done — fix the seed script or overlay data.
cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression"' | tr -d ' \n' - `server/prisma/data/foundry-pf2e/packs/classes/` directory exists with at least 16 .json files (Foundry clone present) - `cd server && npm run db:seed:class-progression` exits 0 - First run reports `≥320 created` for ClassProgression and `≥40 created` for ClassFeatureOption - Second run reports `0 created, ≥320 updated` for ClassProgression (proves idempotency) - `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression"'` outputs at least 320 - `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"'` outputs at least 40 - `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'` returns a row with non-null `spellSlotIncrement` and non-null `cantripIncrement` - `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Cleric'"'"' AND "level" = 1'` returns a row with `choiceType = doctrine` and `choiceOptionsRef = cleric-doctrine` - SEED-README.md has the `` placeholder replaced with the actual tag chosen Seed script runs cleanly twice, all 320+ ClassProgression rows present, all ClassFeatureOption rows present, spell-slot overlay applied to caster classes, choiceType/choiceOptionsRef set on L1 rows for classes with L1 choices.

<threat_model>

Trust Boundaries

Boundary Description
dev-shell → seed script Developer runs the seed script with elevated DB access (DATABASE_URL with write privilege). Trust assumed within self-hosted single-tenant model.
Foundry clone → seed parser The seed reads JSON from a developer-controlled local clone. Trust = developer chooses a clean tag.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-1-W2-01 Tampering Foundry pf2e tag is moved underneath us between seed runs mitigate SEED-README.md instructs the dev to clone a specific pinned tag; the seed script's loud-fail on schema mismatch (Pitfall #5) catches drift.
T-1-W2-02 Tampering Hand-curated overlay contains a bug (e.g. wrong slot count for class X) mitigate Plan 04's integration tests assert ClassProgression-driven recompute results for a representative caster (Wizard L1 cantrips, Sorcerer L5 repertoire). Bugs surface there.
T-1-W2-03 Information Disclosure DATABASE_URL leaked in seed output / error logs accept Self-hosted single-tenant; developer controls .env; no production deploy yet exposes seed script.
T-1-W2-04 Repudiation Seed errors silently corrupt rows mitigate Idempotent pattern with try/catch per row; error count reported in console; non-zero error count exits 1 (CI catches this).
T-1-W2-05 DoS Cascading seed errors flood DB connection pool mitigate Sequential per-row CRUD (no Promise.all); seed completes in <30s for 320+60 rows.
</threat_model>
After all tasks complete:
# README + tag pinned
cat .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md | grep -E "Pinned tag.*[a-z0-9]"

# Overlay file types clean
cd server && npx tsc --noEmit -p tsconfig.json

# Seed runs idempotently (run twice, second run = all updates)
cd server && npm run db:seed:class-progression && npm run db:seed:class-progression

# Row counts
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression"'  # >= 320
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption"'  # >= 40

# Spellcaster verification
psql $DATABASE_URL -c 'SELECT className, level, spellSlotIncrement->>'"'"'tradition'"'"' FROM "ClassProgression" WHERE className IN ('"'"'Wizard'"'"','"'"'Sorcerer'"'"','"'"'Bard'"'"','"'"'Cleric'"'"','"'"'Druid'"'"','"'"'Witch'"'"','"'"'Oracle'"'"') AND spellSlotIncrement IS NOT NULL ORDER BY className, level LIMIT 30'

# Choice-type verification (Cleric L1 should have doctrine choice)
psql $DATABASE_URL -c 'SELECT className, choiceType, choiceOptionsRef FROM "ClassProgression" WHERE level = 1 AND choiceType IS NOT NULL ORDER BY className'

<success_criteria>

  • SEED-README.md exists with cloning instructions and a recorded pinned Foundry tag
  • spell-slot-overlays.ts populated for all 7 caster classes + empty for non-casters; type-clean
  • class-feature-options.ts populated with ≥40 entries across ≥10 distinct optionsRef values; type-clean
  • seed-class-progression.ts type-checks cleanly, imports both overlays, fails loudly on missing Foundry clone
  • Running npm run db:seed:class-progression populates ≥320 ClassProgression rows + ≥40 ClassFeatureOption rows
  • Running it a second time updates 0 created / N updated (idempotent)
  • Wizard, Sorcerer, Bard, Cleric, Druid, Witch, Oracle have non-null spellSlotIncrement at appropriate levels
  • Cleric L1, Wizard L1, Champion L1 (and other classes with L1 choices) have choiceType + choiceOptionsRef set
  • Plan 04 (LevelingService) can prisma.classProgression.findUnique for any (className, level) and get rows </success_criteria>
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md` documenting: - The pinned Foundry pf2e tag chosen - Final row counts (ClassProgression, ClassFeatureOption — by class breakdown) - Any deviations from the planned overlay contents (e.g. Witch tradition default note) - Any classes/levels where Foundry data was incomplete and a hand-curated fallback was applied - Confirmation idempotency observed on second run