953 lines
50 KiB
Markdown
953 lines
50 KiB
Markdown
---
|
||
phase: 01-level-up-pf2e-regelkonform
|
||
plan: 03
|
||
type: execute
|
||
wave: 2
|
||
depends_on: ["01-01", "01-02"]
|
||
files_modified:
|
||
- server/prisma/seed-class-progression.ts
|
||
- server/prisma/data/spell-slot-overlays.ts
|
||
- server/prisma/data/class-feature-options.ts
|
||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||
autonomous: true
|
||
requirements: [LVL-08, LVL-14]
|
||
tags: [seeding, prisma, foundry-pf2e, class-progression, spellcaster, level-up, pipeline]
|
||
must_haves:
|
||
truths:
|
||
- "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."
|
||
artifacts:
|
||
- path: "server/prisma/seed-class-progression.ts"
|
||
provides: "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)."
|
||
exports: ["main (executed when run via tsx)"]
|
||
- path: "server/prisma/data/spell-slot-overlays.ts"
|
||
provides: "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."
|
||
contains: "SPELL_SLOT_OVERLAY"
|
||
- path: "server/prisma/data/class-feature-options.ts"
|
||
provides: "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)."
|
||
contains: "CLASS_FEATURE_OPTIONS"
|
||
- path: ".planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md"
|
||
provides: "Developer instructions: how to clone Foundry pf2e at the pinned tag and run the seed"
|
||
contains: "git clone"
|
||
key_links:
|
||
- from: "seed-class-progression.ts"
|
||
to: "ClassProgression table"
|
||
via: "prisma.classProgression.create / update"
|
||
pattern: "prisma\\.classProgression\\."
|
||
- from: "seed-class-progression.ts"
|
||
to: "ClassFeatureOption table"
|
||
via: "prisma.classFeatureOption.create / update"
|
||
pattern: "prisma\\.classFeatureOption\\."
|
||
- from: "seed-class-progression.ts"
|
||
to: "spell-slot-overlays.ts"
|
||
via: "import SPELL_SLOT_OVERLAY"
|
||
pattern: "from ['\"].*spell-slot-overlays['\"]"
|
||
- from: "seed-class-progression.ts"
|
||
to: "Foundry pf2e clone"
|
||
via: "fs.readFileSync of packs/classes/*.json"
|
||
pattern: "foundry-pf2e.*packs/classes"
|
||
---
|
||
|
||
<objective>
|
||
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.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<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
|
||
|
||
<interfaces>
|
||
<!-- Foundry pf2e class JSON shape (from RESEARCH.md §Code Examples #1, lines 686-717) -->
|
||
<!-- VERIFIED via WebFetch of packs/classes/fighter.json on 2026-04-27 -->
|
||
```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" }
|
||
// …
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
<!-- Existing seed pattern (server/prisma/seed-equipment.ts lines 1-8) -->
|
||
```typescript
|
||
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 });
|
||
```
|
||
|
||
<!-- Idempotent pattern (server/prisma/seed-equipment.ts lines 105-130) -->
|
||
```typescript
|
||
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++;
|
||
}
|
||
}
|
||
```
|
||
|
||
<!-- Spell-slot overlay shape (RESEARCH.md §Code Examples #6, lines 897-924) -->
|
||
```typescript
|
||
export const SPELL_SLOT_OVERLAY: Record<string, Array<{
|
||
level: number;
|
||
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
|
||
cantripIncrement?: number;
|
||
repertoireIncrement?: number;
|
||
}>> = { Wizard: [...], Sorcerer: [...], ... };
|
||
```
|
||
|
||
<!-- The 16 classes in D-16 scope -->
|
||
<!-- Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard -->
|
||
|
||
<!-- Plan 01 schema's compound unique keys (from 01-01-PLAN.md Task 1) -->
|
||
<!-- @@unique([className, level]) on ClassProgression -->
|
||
<!-- @@unique([optionsRef, optionKey]) on ClassFeatureOption -->
|
||
<!-- Prisma generates these as `className_level` and `optionsRef_optionKey` field names. -->
|
||
<!-- If Plan 01's schema declarations are reordered, Prisma swaps the names — -->
|
||
<!-- the seed script below assumes the order shown above. -->
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 1: Dev README for the Foundry pf2e clone path</name>
|
||
<files>
|
||
.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||
</files>
|
||
<read_first>
|
||
- .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)
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<verify>
|
||
<automated>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</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- 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)
|
||
</acceptance_criteria>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 2: Spell-slot overlay file — types + Wizard worked example</name>
|
||
<files>server/prisma/data/spell-slot-overlays.ts</files>
|
||
<read_first>
|
||
- .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)
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<verify>
|
||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "spell-slot-overlays" || echo "tsc clean"</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- 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
|
||
</acceptance_criteria>
|
||
<done>
|
||
Spell-slot overlay file exists with types + Wizard fully populated + stub keys for the other 15 classes. Plan 03b can append without touching shape.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 3: Class-feature-options file — types + Wizard School worked example</name>
|
||
<files>server/prisma/data/class-feature-options.ts</files>
|
||
<read_first>
|
||
- .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
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<verify>
|
||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "class-feature-options" || echo "tsc clean"</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- 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
|
||
</acceptance_criteria>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 4: Seed script — Foundry pf2e + overlays → ClassProgression + ClassFeatureOption</name>
|
||
<files>server/prisma/seed-class-progression.ts</files>
|
||
<read_first>
|
||
- 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)
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<verify>
|
||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "seed-class-progression" || echo "tsc clean"</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- 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
|
||
</acceptance_criteria>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 5: Run the seed (worked-example verification — Wizard end-to-end)</name>
|
||
<files>(no file writes — execution + verification only)</files>
|
||
<read_first>
|
||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||
- server/prisma/seed-class-progression.ts
|
||
- server/package.json (db:seed:class-progression script)
|
||
</read_first>
|
||
<action>
|
||
**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.
|
||
</action>
|
||
<verify>
|
||
<automated>cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"'' | tr -d ' \n'</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `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 `<PINNED_TAG>` placeholder replaced with the actual tag chosen
|
||
</acceptance_criteria>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<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>
|
||
|
||
<verification>
|
||
After all tasks complete:
|
||
|
||
```bash
|
||
# 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
|
||
|
||
# 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'
|
||
```
|
||
</verification>
|
||
|
||
<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>
|
||
|
||
<output>
|
||
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
|
||
</output>
|