Foundry pf2e clone is documented in SEED-README.md (clone command + pinned tag placeholder); .gitignore already excludes the clone path (Plan 01 Task 3 Step 5).
After running `npm run db:seed:class-progression`, the seed pipeline reads Foundry pf2e class JSONs from `server/prisma/data/foundry-pf2e/packs/classes/` and merges with the hand-curated overlays.
After seed, the ClassProgression table contains at least 20 rows for Wizard (L1..L20) as the worked-example class — verifying the pipeline end-to-end.
After seed, Wizard L1..L19 have non-null spellSlotIncrement / cantripIncrement entries from spell-slot-overlays.ts, AND Wizard L1 has choiceType='school' + choiceOptionsRef='wizard-school'.
After seed, at least 1 ClassFeatureOption row exists for optionsRef='wizard-school' (Wizard School L1 worked example).
Seed script is idempotent — running it twice does not duplicate rows; second run reports 0 created / N updated.
Seed script fails loudly with a documented error message pointing the dev to SEED-README.md when the Foundry clone is missing.
spell-slot-overlays.ts and class-feature-options.ts are STRUCTURED so Plan 03b can simply append entries — no schema or shape changes required between plans.
path
provides
exports
server/prisma/seed-class-progression.ts
Idempotent seed transforming Foundry pf2e class JSONs + spell-slot overlay → ClassProgression + ClassFeatureOption rows. The pipeline is generic — it iterates over D16_CLASS_NAMES so adding new classes is data-only (Plan 03b).
main (executed when run via tsx)
path
provides
contains
server/prisma/data/spell-slot-overlays.ts
Type-safe overlay file. Phase 1 ships Wizard fully populated as the worked example; Plan 03b appends entries for Cleric/Druid/Witch/Bard/Sorcerer/Oracle and the empty arrays for non-casters.
SPELL_SLOT_OVERLAY
path
provides
contains
server/prisma/data/class-feature-options.ts
Type-safe option-data file. Phase 1 ships at least 1 Wizard School entry as the worked example; Plan 03b appends ≥49 more entries (Cleric Doctrines, Champion Causes, Sorcerer Bloodlines, etc. — total joint goal: ≥50).
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. This plan owns the **pipeline** (the seed script + the type definitions of the overlay files + Wizard as the fully-curated worked example). Plan 03b runs sequentially in Wave 3 immediately after this plan (sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) and owns the **bulk curation** of remaining caster overlays (≥120 spell-slot entries) and remaining class-feature-options (≥49 more option rows).
Why split: the curation work for the other 6 caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and the 13 other classes' L1 choice points is data-entry from Archives of Nethys — independent rows, no shared logic. Pulling it into its own plan gives the curation a separate review surface; Plan 03b runs sequentially in Wave 3 immediately after this plan (sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) and reuses the type contracts established here without touching the seed script.
Purpose: Plan 04's atomic commit transaction needs ClassProgression rows to know what each (className, level) grants. The pipeline must work end-to-end with Wizard (the worked example) before Plan 03b appends data for the rest.
Output: Seed script + two hand-curated data modules (Wizard fully populated + types defined) + dev README with cloning instructions. Plan 03b appends to the data modules without touching the seed script.
Task 1: Dev README for the Foundry pf2e clone path
.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 — verify before this task)
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
```
`server/prisma/data/foundry-pf2e/` is excluded from version control (.gitignore line added in Plan 01).
Do NOT commit the clone. Re-clone after a Foundry pf2e major version ships.
**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 (cumulative across Plan 03 and Plan 03b)
- **ClassProgression** rows: 16 classes × 20 levels = 320 rows, with `grants[]`,
`proficiencyChanges`, `spellSlotIncrement`, `cantripIncrement`, `repertoireIncrement`,
`choiceType`, `choiceOptionsRef`. Plan 03 ships Wizard L1..L20 fully (worked example);
Plan 03b adds remaining 15 classes' L1..L20 rows (data-only — Plan 03's pipeline already
seeds 320 rows; Plan 03b just enriches them with overlay data).
- **ClassFeatureOption** rows: hand-curated Cleric Doctrines, Wizard Schools, Champion
Causes, Sorcerer Bloodlines (where L1-set), Druid Orders, etc. Joint goal across both
plans: ≥50 rows. Plan 03 ships at least 1 (Wizard School worked example); Plan 03b
ships ≥49 more.
- 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). Plan 03 ships Wizard's full L1..L19 entries; Plan 03b ships the other
6 caster classes plus empty arrays for non-casters.
```
Note: The `<PINNED_TAG>` placeholder is filled in during execution when the dev/executor selects a tag.
Verify: `ls .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` shows the file.
The Foundry clone directory is created later by the dev (Task 5 step 1) following the README.
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)
- File mentions both Plan 03 (Wizard worked example) AND Plan 03b (bulk curation)
- No file is created inside `server/prisma/data/foundry-pf2e/` (the directory is dev-time only — gitignored)
Dev README documents the clone step and the run step, and explicitly references the Plan 03 / Plan 03b split. No tracked files inside the gitignored data dir.
Task 2: Spell-slot overlay file — types + Wizard worked example
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 Wizard spell table: https://2e.aonprd.com/Classes.aspx?ID=18 (or current canonical URL)
Create `server/prisma/data/spell-slot-overlays.ts` containing:
1. The TS types (`SpellTradition`, `SpellSlotOverlayEntry`) — these are the contract Plan 03b appends to.
2. Wizard fully populated for L1..L19 (the worked example used by the seed pipeline test in Task 5).
3. Stub keys for the other 15 D-16 classes set to empty arrays — Plan 03b populates these.
**File contents:**
```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). This file (Plan 03) ships the type definitions
* and Wizard fully populated as the worked example. Plan 03b appends entries for the
* remaining 6 caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and the
* empty-array entries for non-casters.
*
* 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 CASTER — WIZARD (worked example, fully populated in Plan 03) ===
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)
],
// === STUBS — populated by Plan 03b ===
// Caster stubs (Plan 03b will replace [] with full L1..L20 entries):
Cleric: [],
Druid: [],
Witch: [],
Bard: [],
Sorcerer: [],
Oracle: [],
Champion: [], // focus-only; minimal entries
// Non-caster stubs (Plan 03b confirms these stay empty):
Alchemist: [],
Barbarian: [],
Fighter: [],
Investigator: [],
Monk: [],
Ranger: [],
Rogue: [],
Swashbuckler: [],
};
```
**Constraint:** No `: any` types. Every entry strictly typed against `SpellSlotOverlayEntry`.
**Constraint:** Every D-16 class name must appear as a key (even with `[]`) so Plan 03b can simply replace `[]` with curated entries — preventing accidental missing-key bugs in the seed.
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 `level:` keys in Wizard array)
- Cleric/Druid/Witch/Bard/Sorcerer/Oracle keys exist (their values may be `[]` — those are populated by Plan 03b)
- File contains NO `: any` outside comments
- `cd server && npx tsc --noEmit` exits 0
Spell-slot overlay file exists with types + Wizard fully populated + stub keys for the other 15 classes. Plan 03b can append without touching shape.
Task 3: Class-feature-options file — types + Wizard School worked example
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 RESOLVED recommendation)
- server/prisma/schema.prisma (post-Plan-01 — ClassFeatureOption model)
- Archives of Nethys Wizard schools entry
Create `server/prisma/data/class-feature-options.ts` containing:
1. The TS interface `ClassFeatureOptionEntry` — the contract Plan 03b appends to.
2. At least 1 Wizard School entry (Battle Magic) as the worked example.
3. Section-headed comment blocks for all the other classes with `// (populated by Plan 03b)` placeholders so Plan 03b knows exactly where to append.
**File contents:**
```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 RESOLVED — 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.
* SPLIT: Plan 03 ships Wizard School (worked example, ≥1 entry). Plan 03b ships the
* remaining classes — joint goal across both plans is ≥50 entries.
*/
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
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[] = [
// ============================================================
// === WIZARD SCHOOL — worked example (Plan 03 ships ≥1 entry)
// === optionsRef: 'wizard-school'
// ============================================================
{
optionsRef: 'wizard-school',
optionKey: 'battle-magic',
name: 'School of Battle Magic',
description: 'You focus on offensive evocations and battlefield control, learning to bring overwhelming magical force to bear against your foes.',
grants: ['Battle Magic Curriculum', 'School Spell: Force Bolt'],
},
// (Plan 03b appends remaining Wizard schools: civic-wizardry, mentalism, protean-form, unified-magical-theory, universalist)
// ============================================================
// === Plan 03b appends entries below — section anchors:
// ============================================================
// === CLERIC DOCTRINE (optionsRef: 'cleric-doctrine') — Plan 03b
// === CHAMPION CAUSE (optionsRef: 'champion-cause') — Plan 03b
// === DRUID ORDER (optionsRef: 'druid-order') — Plan 03b
// === SORCERER BLOOD. (optionsRef: 'sorcerer-bloodline') — Plan 03b
// === BARD MUSE (optionsRef: 'bard-muse') — Plan 03b
// === BARBARIAN INST. (optionsRef: 'barbarian-instinct') — Plan 03b
// === WITCH PATRON (optionsRef: 'witch-patron') — Plan 03b
// === ORACLE MYSTERY (optionsRef: 'oracle-mystery') — Plan 03b
// === INVESTIGATOR (optionsRef: 'investigator-methodology') — Plan 03b
// === RANGER EDGE (optionsRef: 'ranger-edge') — Plan 03b
// === ROGUE RACKET (optionsRef: 'rogue-racket') — Plan 03b
// === SWASHBUCKLER (optionsRef: 'swashbuckler-style') — Plan 03b
// === ALCHEMIST RES. (optionsRef: 'alchemist-research-field') — Plan 03b
// (Fighter / Monk: no L1 choice — Fighter L5 weapon-mastery and Monk L1 stance via class feats)
];
```
**Cross-references the executor (and Plan 03b) must maintain:**
- `optionsRef` strings must match the `choiceOptionsRef` values used in `seed-class-progression.ts` (Task 4 below) when it sets `choiceType` and `choiceOptionsRef` on the L1 ClassProgression rows.
- `optionKey` values are stable identifiers — once chosen, do not rename (would break existing 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 (Plan 03b will add) cross-reference the existing `ResearchField` enum in `server/prisma/schema.prisma` (BOMBER, CHIRURGEON, etc.) — `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 1 entry with `optionsRef: 'wizard-school'` (Plan 03 worked example)
- Every entry has a non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` field
- File contains anchor comments naming all the other class optionsRef strings (so Plan 03b knows exactly where to insert)
- File contains NO `: any` outside comments
- `cd server && npx tsc --noEmit` exits 0
Class-feature options file exists with types + Wizard School worked example + anchor comments for Plan 03b. The Wizard L1 choice is functional end-to-end after Task 5.
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 — verify the `@@unique` order before assuming compound-key field names)
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.
*
* Compound unique keys (generated by Prisma from Plan 01 schema):
* - ClassProgression has `@@unique([className, level])` -> field name: `className_level`
* - ClassFeatureOption has `@@unique([optionsRef, optionKey])` -> field name: `optionsRef_optionKey`
* If Plan 01's `@@unique([...])` declarations are reordered, Prisma swaps the field names.
* Verify Plan 01's schema field order before assuming. If the order changes, update the
* findUnique calls below accordingly.
*/
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.
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.
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 x 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 {
// Compound unique key field name `className_level` is generated from
// `@@unique([className, level])` declared in Plan 01's schema. If that
// declaration is reordered, Prisma will name the key `level_className`
// instead — verify schema before changing.
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 {
// Compound unique key field name `optionsRef_optionKey` is generated from
// `@@unique([optionsRef, optionKey])` in Plan 01's schema. Same caveat as above
// applies — verify the field order before touching this.
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('\nClassProgression + 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.
- The script is **generic** — it iterates over D16_CLASS_NAMES and SPELL_SLOT_OVERLAY/CLASS_FEATURE_OPTIONS, so when Plan 03b appends entries to those modules nothing in this script needs to change.
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 the compound-key explanatory comment about `@@unique` ordering
- 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. Generic over D16_CLASS_NAMES so Plan 03b is data-only.
Task 5: Run the seed (worked-example verification — Wizard end-to-end)
(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
```
Plan 03 ships only Wizard fully populated in the overlays, so the expected output for THIS plan (before Plan 03b runs) is approximately:
```
Seeding ClassProgression for 16 classes x 20 levels...
ClassProgression: 320 created, 0 updated, 0 errors
Seeding 1 ClassFeatureOption rows...
ClassFeatureOption: 1 created, 0 updated, 0 errors
ClassProgression + ClassFeatureOption seed complete.
```
(The 320 ClassProgression rows are populated regardless — the overlays just leave non-Wizard rows with null spellSlotIncrement / null choiceOptionsRef. Plan 03b updates those rows in place.)
**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, 1 updated, 0 errors` for ClassFeatureOption.
**Step 5 — Verify Wizard worked example end-to-end:**
```bash
psql $DATABASE_URL -c 'SELECT "level", "spellSlotIncrement", "cantripIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' ORDER BY "level" LIMIT 5'
```
Expected: rows for Wizard L1..L5 with non-null `spellSlotIncrement` JSON containing `tradition: ARCANE`. L1 also has cantripIncrement=5.
```bash
psql $DATABASE_URL -c 'SELECT "className", "choiceType", "choiceOptionsRef" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'
```
Expected: 1 row with `choiceType=school`, `choiceOptionsRef=wizard-school`.
```bash
psql $DATABASE_URL -c 'SELECT "optionKey", "name" FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"''
```
Expected: at least 1 row (Wizard School worked example: `battle-magic`).
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" WHERE "className" = '"'"'Wizard'"'"'' | 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 `≥1 created` for ClassFeatureOption
- Second run reports `0 created, ≥320 updated` for ClassProgression (proves idempotency)
- `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"''` outputs at least 20 (Wizard L1..L20)
- `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"''` outputs at least 1
- `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'` returns a row with non-null `spellSlotIncrement`, non-null `cantripIncrement`, `choiceType=school`, `choiceOptionsRef=wizard-school`
- SEED-README.md has the `` placeholder replaced with the actual tag chosen
Seed script runs cleanly twice; Wizard worked example fully populated end-to-end (20 ClassProgression rows + 1+ ClassFeatureOption row). Plan 03b can append further data and re-run to bulk-update the remaining classes without changes to the script.
<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). 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+50 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 cleancd 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
# Wizard worked-example row counts
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"''# >= 20
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"''# >= 1# Wizard spellcaster verification
psql $DATABASE_URL -c 'SELECT level, "spellSlotIncrement", "cantripIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "spellSlotIncrement" IS NOT NULL ORDER BY level LIMIT 10'# Wizard L1 choice verification
psql $DATABASE_URL -c 'SELECT "choiceType", "choiceOptionsRef" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND level = 1'
<success_criteria>
SEED-README.md exists with cloning instructions and a recorded pinned Foundry tag
spell-slot-overlays.ts exports types + Wizard fully populated for L1..L19; type-clean
class-feature-options.ts exports types + at least 1 Wizard School entry; type-clean
seed-class-progression.ts type-checks cleanly, imports both overlays, fails loudly on missing Foundry clone, generic over D16_CLASS_NAMES
Running npm run db:seed:class-progression populates ≥320 ClassProgression rows
Running it a second time updates 0 created / N updated (idempotent)
Wizard L1..L19 have non-null spellSlotIncrement at appropriate levels
Wizard L1 has choiceType=school + choiceOptionsRef=wizard-school
ClassFeatureOption has ≥1 row for optionsRef=wizard-school (worked example)
Plan 04 (LevelingService) can prisma.classProgression.findUnique for (Wizard, 1)..(Wizard, 20) and get rows with overlay data
Plan 03b (runs sequentially in Wave 3 immediately after this plan — sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) has a clear contract: append entries to spell-slot-overlays.ts + class-feature-options.ts, then re-run seed to bulk-populate the other classes
</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 after Plan 03 alone (ClassProgression: ~320; ClassFeatureOption: ≥1)
- Confirmation that Wizard L1..L19 spell-slot entries are correct against AoN
- Confirmation idempotency observed on second run
- Note that Plan 03b will append data and re-run the seed without code changes