From 1a9e5e2c1896ca7945a5b64fcc8a94d79bf5de0d Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 00:09:42 +0200 Subject: [PATCH] Build MVP: campaigns, characters, combat, dice, compendium - Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/launch.json | 11 + README.md | 66 + docs/OLD_BUG_HUNT_REPORT.md | 264 + e2e/smoke.spec.ts | 59 + playwright.config.ts | 20 + public/favicon.svg | 11 + public/pwa-192x192.png | Bin 0 -> 9362 bytes public/pwa-512x512.png | Bin 0 -> 24605 bytes scripts/fetch_data.py | 70 + scripts/parse_mpmb.py | 123 + src/app/RootLayout.tsx | 91 + src/components/ui/Button.tsx | 44 + src/components/ui/ErrorBoundary.tsx | 50 + src/components/ui/Input.tsx | 33 + src/components/ui/Modal.tsx | 80 + src/components/ui/NumberField.tsx | 41 + src/components/ui/Page.tsx | 59 + src/data/srd/actions.json | 68 + src/data/srd/conditions.json | 119 + src/data/srd/equipment.json | 29 + src/data/srd/magicitems-full.json | 17800 + src/data/srd/magicitems-srd.json | 2609 + src/data/srd/monsters-full.json | 280715 +++++++++++++++ src/data/srd/monsters-sample.json | 592 + src/data/srd/monsters-srd.json | 28513 ++ src/data/srd/mpmb-ammo.json | 76 + src/data/srd/mpmb-background.json | 874 + src/data/srd/mpmb-backgroundfeature.json | 1397 + src/data/srd/mpmb-class.json | 29 + src/data/srd/mpmb-companion.json | 12 + src/data/srd/mpmb-creature.json | 1024 + src/data/srd/mpmb-feats.json | 1176 + src/data/srd/mpmb-gear.json | 63 + src/data/srd/mpmb-magicitems.json | 7391 + src/data/srd/mpmb-race.json | 1324 + src/data/srd/mpmb-source.json | 80 + src/data/srd/mpmb-spells.json | 2312 + src/data/srd/mpmb-weapons.json | 457 + src/data/srd/rules.json | 44 + src/data/srd/spells-full.json | 47753 +++ src/data/srd/spells-sample.json | 22 + src/data/srd/spells-srd.json | 11427 + src/data/srd/weapons-full.json | 1157 + src/data/srd/weapons-srd.json | 618 + src/features/campaigns/CampaignsPage.tsx | 200 + src/features/campaigns/hooks.ts | 17 + src/features/characters/CharacterSheet.tsx | 297 + .../characters/CharacterSheetPage.tsx | 20 + src/features/characters/CharactersPage.tsx | 164 + src/features/characters/hooks.ts | 11 + src/features/combat/CombatPage.tsx | 128 + src/features/combat/EncounterTracker.tsx | 312 + src/features/combat/hooks.ts | 15 + src/features/compendium/CompendiumPage.tsx | 219 + src/features/compendium/MonsterDetail.tsx | 79 + src/features/dice/DicePage.tsx | 117 + src/lib/cn.ts | 7 + src/lib/combat/engine.test.ts | 150 + src/lib/combat/engine.ts | 149 + src/lib/compendium/index.ts | 56 + src/lib/compendium/types.ts | 75 + src/lib/db/db.ts | 32 + src/lib/db/repositories.test.ts | 47 + src/lib/db/repositories.ts | 168 + src/lib/dice/notation.test.ts | 67 + src/lib/dice/notation.ts | 171 + src/lib/format.ts | 4 + src/lib/ids.ts | 6 + src/lib/rng.ts | 58 + src/lib/rules/abilities.ts | 32 + src/lib/rules/dnd5e/index.ts | 84 + src/lib/rules/dnd5e/skills.ts | 22 + src/lib/rules/index.ts | 20 + src/lib/rules/pf2e/index.ts | 78 + src/lib/rules/pf2e/skills.ts | 27 + src/lib/rules/rules.test.ts | 103 + src/lib/rules/types.ts | 74 + src/lib/schemas/campaign.ts | 22 + src/lib/schemas/character.ts | 51 + src/lib/schemas/common.ts | 40 + src/lib/schemas/dice.ts | 14 + src/lib/schemas/encounter.ts | 39 + src/lib/schemas/index.ts | 5 + src/lib/useDebouncedCallback.ts | 45 + src/main.tsx | 18 + src/router.tsx | 38 + src/stores/uiStore.ts | 46 + src/vite-env.d.ts | 2 + tsconfig.app.tsbuildinfo | 1 + tsconfig.node.json | 2 +- tsconfig.node.tsbuildinfo | 1 + vite.config.ts | 8 - vitest.config.ts | 17 + 93 files changed, 412052 insertions(+), 9 deletions(-) create mode 100644 .claude/launch.json create mode 100644 README.md create mode 100644 docs/OLD_BUG_HUNT_REPORT.md create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 public/favicon.svg create mode 100644 public/pwa-192x192.png create mode 100644 public/pwa-512x512.png create mode 100644 scripts/fetch_data.py create mode 100644 scripts/parse_mpmb.py create mode 100644 src/app/RootLayout.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/ErrorBoundary.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/NumberField.tsx create mode 100644 src/components/ui/Page.tsx create mode 100644 src/data/srd/actions.json create mode 100644 src/data/srd/conditions.json create mode 100644 src/data/srd/equipment.json create mode 100644 src/data/srd/magicitems-full.json create mode 100644 src/data/srd/magicitems-srd.json create mode 100644 src/data/srd/monsters-full.json create mode 100644 src/data/srd/monsters-sample.json create mode 100644 src/data/srd/monsters-srd.json create mode 100644 src/data/srd/mpmb-ammo.json create mode 100644 src/data/srd/mpmb-background.json create mode 100644 src/data/srd/mpmb-backgroundfeature.json create mode 100644 src/data/srd/mpmb-class.json create mode 100644 src/data/srd/mpmb-companion.json create mode 100644 src/data/srd/mpmb-creature.json create mode 100644 src/data/srd/mpmb-feats.json create mode 100644 src/data/srd/mpmb-gear.json create mode 100644 src/data/srd/mpmb-magicitems.json create mode 100644 src/data/srd/mpmb-race.json create mode 100644 src/data/srd/mpmb-source.json create mode 100644 src/data/srd/mpmb-spells.json create mode 100644 src/data/srd/mpmb-weapons.json create mode 100644 src/data/srd/rules.json create mode 100644 src/data/srd/spells-full.json create mode 100644 src/data/srd/spells-sample.json create mode 100644 src/data/srd/spells-srd.json create mode 100644 src/data/srd/weapons-full.json create mode 100644 src/data/srd/weapons-srd.json create mode 100644 src/features/campaigns/CampaignsPage.tsx create mode 100644 src/features/campaigns/hooks.ts create mode 100644 src/features/characters/CharacterSheet.tsx create mode 100644 src/features/characters/CharacterSheetPage.tsx create mode 100644 src/features/characters/CharactersPage.tsx create mode 100644 src/features/characters/hooks.ts create mode 100644 src/features/combat/CombatPage.tsx create mode 100644 src/features/combat/EncounterTracker.tsx create mode 100644 src/features/combat/hooks.ts create mode 100644 src/features/compendium/CompendiumPage.tsx create mode 100644 src/features/compendium/MonsterDetail.tsx create mode 100644 src/features/dice/DicePage.tsx create mode 100644 src/lib/cn.ts create mode 100644 src/lib/combat/engine.test.ts create mode 100644 src/lib/combat/engine.ts create mode 100644 src/lib/compendium/index.ts create mode 100644 src/lib/compendium/types.ts create mode 100644 src/lib/db/db.ts create mode 100644 src/lib/db/repositories.test.ts create mode 100644 src/lib/db/repositories.ts create mode 100644 src/lib/dice/notation.test.ts create mode 100644 src/lib/dice/notation.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/ids.ts create mode 100644 src/lib/rng.ts create mode 100644 src/lib/rules/abilities.ts create mode 100644 src/lib/rules/dnd5e/index.ts create mode 100644 src/lib/rules/dnd5e/skills.ts create mode 100644 src/lib/rules/index.ts create mode 100644 src/lib/rules/pf2e/index.ts create mode 100644 src/lib/rules/pf2e/skills.ts create mode 100644 src/lib/rules/rules.test.ts create mode 100644 src/lib/rules/types.ts create mode 100644 src/lib/schemas/campaign.ts create mode 100644 src/lib/schemas/character.ts create mode 100644 src/lib/schemas/common.ts create mode 100644 src/lib/schemas/dice.ts create mode 100644 src/lib/schemas/encounter.ts create mode 100644 src/lib/schemas/index.ts create mode 100644 src/lib/useDebouncedCallback.ts create mode 100644 src/main.tsx create mode 100644 src/router.tsx create mode 100644 src/stores/uiStore.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.tsbuildinfo create mode 100644 tsconfig.node.tsbuildinfo create mode 100644 vitest.config.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..69307dc --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..806fd96 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# TTRPG Manager + +A local-first campaign manager for **D&D 5e** and **Pathfinder 2e**, built for both +Game Masters and players. Runs entirely in the browser (installable as a PWA) — your +data lives in IndexedDB on your device; no account, no server. + +## Features (MVP) + +- **Campaigns** — create per-system campaigns; deleting one cascades to all its data. +- **Characters** — 5e/PF2e sheets with live derived stats: ability modifiers, + proficiency, skills, saves, AC, initiative, HP (with damage/heal/temp). Autosaves. +- **Combat tracker** — initiative order, round/turn tracking, conditions, HP, and + add/remove/reorder mid-fight without ever corrupting whose turn it is. +- **Dice** — full notation (`2d6+3`, `4d6kh3`), advantage/disadvantage, seedable RNG, + per-campaign roll history. +- **Compendium** — searchable SRD bestiary, spells, and magic items. Add any monster + straight into the open encounter. + +## Tech + +- React 19 + TypeScript (strict) + Vite +- TanStack Router · Zustand · Zod · Dexie (IndexedDB) · Tailwind v4 · Fuse.js +- Vitest (unit) + Playwright (e2e), PWA via vite-plugin-pwa + +## Architecture + +``` +src/ + lib/ + rules/ # game-system abstraction — 5e & pf2e behind one RulesSystem interface + combat/ # pure, tested turn-order + HP engine + dice/ # notation parser + roller + db/ # Dexie schema + repositories (cascade deletes, validation on write) + schemas/ # Zod entity schemas — every number rejects NaN/Infinity + compendium/ # lazy SRD data loaders (code-split out of the main bundle) + features/ # campaigns, characters, combat, dice, compendium (UI) + components/ui/ # design-system primitives + data/srd/ # SRD JSON (Open5e + MPMB); regenerate with scripts/fetch_data.py +``` + +The rules layer means features never branch on `if (system === '5e')`; they call the +`RulesSystem` interface, so adding a system is a self-contained module. + +## Development + +```bash +bun install +bun run dev # dev server at http://localhost:5173 +bun run test # unit tests (vitest) +bun run test:e2e # end-to-end (playwright) +bun run build # typecheck + production build +bun run lint +``` + +## Roadmap + +Notes/journal, maps with fog of war, encounter/loot builders, a shared player view, +PF2e compendium data, and an optional sync backend for real-time tables. + +## History + +This is a from-scratch rebuild. The previous implementation (deleted) had ~135 +documented bugs and no version control; its bug report is kept at +`docs/OLD_BUG_HUNT_REPORT.md` as a record of failure modes designed against here +(silent data loss, turn-order corruption, no cascade deletes, NaN inputs, dead +Tauri integration). The SRD data and Python scrapers were preserved. diff --git a/docs/OLD_BUG_HUNT_REPORT.md b/docs/OLD_BUG_HUNT_REPORT.md new file mode 100644 index 0000000..a1c38ec --- /dev/null +++ b/docs/OLD_BUG_HUNT_REPORT.md @@ -0,0 +1,264 @@ +# TTRPG Manager — Comprehensive Bug Hunt Report + +**Date**: 2026-06-03 +**Methodology**: 24 parallel AI agents searched 100+ source files across 20+ bug categories. Wave 1: 16 agents. Wave 2: 8 agents (addressing Oracle-identified gaps). +**Baseline**: 197 existing tests all pass (math engine only; 0 store/adapter/UI tests). + +--- + +## 🔴 CRITICAL (19 bugs — data loss, crashes, security incidents) + +### Infrastructure & Config +| ID | Finding | File | +|----|---------|------| +| C1 | **`tsconfig.app.json` missing `"strict": true`** — no strictNullChecks, noImplicitAny | `tsconfig.app.json` | +| C2 | **No React ErrorBoundary** — any uncaught render error white-screens entire app in React 19 | `App.tsx` | +| C3 | **CSP set to `null`** — all Content Security Policy protections disabled | `tauri.conf.json:23` | + +### Tauri Integration +| ID | Finding | File | +|----|---------|------| +| C4 | **`capabilities.json` is `{}`** — no Tauri v2 permissions | `src-tauri/gen/schemas/capabilities.json` | +| C5 | **`import_campaign`/`export_campaign` are no-op stubs** | `commands.rs:1-9` | +| C6 | **Zero Tauri API usage in frontend** — no `@tauri-apps` imports | All `src/` | +| C7 | **SQLite configured but never queried** — no SQL adapter implemented | `tauri.conf.json:27-31`, `storage/instance.ts:8` | + +### Storage & Data Integrity +| ID | Finding | File | +|----|---------|------| +| C8 | **Transaction completion not checked** — `promisify` resolves on `request.onsuccess` BEFORE transaction commits. All 30+ writes can silently lose data. | `indexeddb.ts:62-67` | +| C9 | **Campaign deletion does NOT cascade** — 9 child entity types orphaned forever | `campaignStore.ts:80-86` | +| C10 | **Handout data split across localStorage AND IndexedDB** — export/import silently loses all handouts | `handoutStore.ts` / `types.ts:142-150` | +| C11 | **Combat encounter IDs inside `combatantsJson` not remapped on import** | `indexeddb.ts:628-629` | +| C12 | **`updateSettings` reads stale data across separate transactions** | `indexeddb.ts:518-525` | +| C13 | **No data migration strategy** — `DB_VERSION = 1` hardcoded, `onupgradeneeded` only creates new stores | `indexeddb.ts:30, 651-723` | + +### Security +| ID | Finding | File | +|----|---------|------| +| C14 | **No HTML sanitization library** — `contentMd` stores raw HTML (not Markdown). DOMPurify missing. | All note components | + +### State Management +| ID | Finding | File | +|----|---------|------| +| C15 | **Direct state mutation in handoutStore** — localStorage split-brain with IndexedDB; dual `Handout` type definitions | `handoutStore.ts:99-105` | + +### Combat System +| ID | Finding | File | +|----|---------|------| +| C16 | **`removeCombatant` corrupts turn order** — doesn't decrement index when removing combatant before current turn | `combatStore.ts:165-175` | +| C17 | **`addCombatant`/`updateCombatant`/`reorderCombatants` don't adjust `currentTurnIndex`** | `combatStore.ts:143-150`, `152-163`, `222-229` | +| C18 | **`nextTurn()` calls async `nextRound()` without `await`** — race condition | `combatStore.ts:191-193` | + +### Race Conditions (Wave 2) +| ID | Finding | File | +|----|---------|------| +| C19 | **Read-modify-write without locking in all stores** — two rapid mutations can overwrite each other (e.g., double `addCombatant`) | All store files | + +--- + +## 🟠 HIGH (16 bugs — functional breakage under realistic conditions) + +### Tauri & Storage +- **H1**: `init()` race — second caller returns promise resolving before `ensureSettings()` seeds defaults. `indexeddb.ts:97-104` +- **H2**: No `onblocked` handler on DB open — hangs indefinitely if another tab has connection. `indexeddb.ts:648-734` +- **H3**: DB connection not reset after unexpected close — `onclose` sets `db=null` but `initPromise` stays stale. `indexeddb.ts:725-730` +- **H4**: `handleClearAll` deletes campaigns but not related entities. `SettingsPage.tsx:148-156` +- **H5**: Full table scan on dice roll history — `getAll()` loads every roll. `indexeddb.ts:415-423` +- **H6**: Full table scan on note search — Fuse.js index rebuilt every keystroke. `indexeddb.ts:369-380` + +### Math & Game Mechanics +- **H7**: Heavy armor incorrectly applies negative DEX modifier to AC — `Math.min(-1, 0) = -1`. `five-e/combat.ts:137-138` +- **H8**: `rollD20` / `recoveryCheck` use `Math.random()` despite seedable RNG available. `five-e/rolling.ts:35-36` +- **H9**: Recovery check returns `'success'` not `'criticalSuccess'` for nat 20. `pf2e/dying.ts:44-46` + +### Combat System +- **H10**: No tie-breaking in initiative sort — unstable ordering. `combatStore.ts:130` +- **H11**: Reaction resets at end of turn instead of start — double-dipping on reactions. `CombatScreen.tsx:67-80` +- **H12**: Short Rest fully heals instead of requiring Hit Dice rolls. `CombatDetail.tsx:153-154` + +### Character System +- **H13**: `ProficiencyRank` type conflict — numeric vs string across two modules. `shared.ts:20` vs `types.ts:12-18` +- **H14**: PF2e HP damage bypasses temporary HP entirely. `CharacterSheetPF2e.tsx:60-62` + +### Data Integrity +- **H15**: NaN/Infinity accepted in ALL combat number inputs (7+ locations). `HPManager.tsx:83`, etc. +- **H16**: SettingsPage "Export All" produces non-importable format. `SettingsPage.tsx:129-134` + +--- + +## 🟡 MEDIUM (16 bugs — degraded behavior, fragile states) + +### Combat +- **M1**: Dead/unconscious combatants remain in initiative — no skip mechanism. `combatStore.ts:185-194` +- **M2**: HP clamped at 0 — no instant death / massive damage tracking. `HPManager.tsx:31-43` +- **M3**: Shield Block allowed when shield lowered/broken (PF2e rules). `ShieldManager.tsx:151` +- **M4**: PF2e custom condition value only added when > 1, presets use > 0 — inconsistency. `ConditionManager.tsx:74` + +### Character System +- **M5**: Character sheet defaults rendered but NEVER persisted. `CharacterSheet5e.tsx:510-531` +- **M6**: Spell slot / hit dice "used" not clamped when "total" reduced. `CharacterSheet5e.tsx:326,385` +- **M7**: No auto-save — unsaved changes silently lost on navigation. `CharacterSheet5e.tsx:534-538` +- **M8**: `importDDBCharacter` does zero actual D&D Beyond parsing. `characterStore.ts:91-109` + +### Storage +- **M9**: `Number("0") || 1` coalesces zero to one in ConditionManager. `ConditionManager.tsx:36` +- **M10**: `parseHp` returns HP object with potentially zero `max` causing NaN% bar. `CharacterList.tsx:18-26` + +### React Patterns +- **M11**: `useMemo` used for side-effects — should be `useEffect`. `BestiarySearch.tsx:157-159` +- **M12**: `useCallback` misused as `useMemo` in QuestDetail. `QuestDetail.tsx:219-233` +- **M13**: Stale closures from `eslint-disable react-hooks/exhaustive-deps` — 9+ instances. + +### Accessibility +- **M14**: No `aria-live` regions — screen readers get no dynamic announcements. +- **M15**: No focus trapping in any modal (4 modals affected). +- **M16**: Icon-only buttons missing `aria-label` — 8 instances. + +--- + +## 🟢 LOW (17 bugs — maintenance concerns, minor UX) + +### UI/UX +- **L1**: Fixed sidebar widths not responsive. Multiple components. +- **L2**: Delete buttons invisible until parent hover — undiscoverable for keyboard users. +- **L3**: Color contrast: `text-dnd-muted` (#8a8a9a) on `bg-dnd-bg` (#1a1a2e) ~3.8:1 — fails WCAG AA. +- **L4**: Version number not semantic. `App.tsx:102` + +### Edge Cases +- **L5**: No level bounds on proficiency bonus — level 0→+1, level 1000→+251. +- **L6**: Fractional CR monsters corrupted to CR 0 — `Number("1/8")` = NaN. +- **L7**: `advanceDay()` crashes on invalid date — RangeError on `toISOString()`. + +### Performance +- **L8**: Zero `React.memo` usage — entire tree rerenders on any change. +- **L9**: No code splitting — 10+ features eagerly imported including MB-scale JSON. +- **L10**: Synchronous `localStorage` on every handout/calendar mutation. + +### Theme System (Wave 2) +- **L11**: **Light theme is a null-op** — `AppSettings.theme` accepts `'light'` but `globals.css` defines only dark colors. Toggling theme silently does nothing. + +### Python Scripts (Wave 2) +- **L12**: `fetch_data.py` contains hardcoded paths (`/home/nilsb/Downloads/`) — won't work on other machines. +- **L13**: `parse_mpmb.py` regex parsing has no error recovery on malformed input. + +### PWA (Wave 2) +- **L14**: Service worker registered but no update prompt — old SW persists indefinitely until manual unregister. + +### Dead Code (Wave 2) +- **L15**: `@tanstack/react-query` imported but barely used — 13KB gzipped dead weight. +- **L16**: `idMap` populated but never read in `importCampaign`. `indexeddb.ts:572-577` +- **L17**: `data-tab` attributes set but never consumed. `App.tsx:122` + +--- + +## 📊 Summary by System Area + +| System Area | Crit | High | Med | Low | Total | +|-------------|------|------|-----|-----|-------| +| Storage/IndexedDB | 6 | 6 | 2 | 0 | **14** | +| Combat System | 3 | 3 | 4 | 0 | **10** | +| Tauri Integration | 4 | 0 | 0 | 0 | **4** | +| Character System | 0 | 2 | 4 | 0 | **6** | +| State Management | 1 | 0 | 0 | 0 | **1** | +| Math/Game Mechanics | 0 | 3 | 0 | 2 | **5** | +| Security | 1 | 0 | 0 | 0 | **1** | +| React/Accessibility | 0 | 0 | 3 | 3 | **6** | +| Infrastructure | 3 | 0 | 0 | 0 | **3** | +| Data Integrity | 0 | 2 | 2 | 0 | **4** | +| Performance | 0 | 0 | 0 | 3 | **3** | +| Race Conditions | 1 | 0 | 0 | 0 | **1** | +| Theme/PWA/Python | 0 | 0 | 0 | 4 | **4** | +| Dead Code | 0 | 0 | 0 | 3 | **3** | +| **TOTAL** | **19** | **16** | **15** | **15** | **65** | + +--- + +## 🎯 Fix Priority Roadmap + +### Week 1 — Critical (19 bugs) +1. Enable `"strict": true` in tsconfig +2. Add React ErrorBoundary wrapping `
` in App.tsx +3. Add DOMPurify sanitization for all note content rendering +4. Fix `promisify` to await `transaction.oncomplete` (or use `transaction.done`) +5. Implement cascade delete for campaigns +6. Fix combatStore turn index on remove/add/reorder +7. Add form-level input validation (NaN/Infinity guards) + +### Week 2 — High (16 bugs) +8. Fix heavy armor DEX modifier bug +9. Fix PF2e temp HP bypass +10. Replace `Math.random()` with seedable RNG for game rolls +11. Unify Handout types and migrate localStorage→IndexedDB +12. Fix stale closures in detail components +13. Fix short rest to use Hit Dice +14. Add `onblocked` handler to IndexedDB open + +### Week 3 — Medium (15 bugs) +15. Add dead combatant skipping in initiative +16. Fix character sheet default persistence +17. Add `aria-live` regions and focus trapping +18. Replace `useMemo` side-effects with `useEffect` +19. Fix `Number("0") || 1` to use nullish coalescing + +### Backlog — Low (15 bugs) +- Performance: add React.memo, code splitting +- Accessibility: fix color contrast, responsive widths +- Theme: implement light theme or remove the setting +- Python: fix hardcoded paths +- PWA: add update prompt +- Dead code: remove unused imports and `@tanstack/react-query` + +--- + +--- + +## 🔴 Wave 3 — Oracle-Identified Gap Closures + +### CombatMeta Data Loss (HIGH) +- **W3-1**: `combatMeta` stored in React `useState` — lost on tab switch. Temp HP, shield HP, used actions, MAP counts all reset. `CombatScreen.tsx:34` + +### MapCanvas Touch Support (HIGH) +- **W3-2**: MapCanvas uses mouse events only (`onMouseDown/Up/Move/Wheel`) — no touch equivalents. Fog of war revelation impossible on tablets. `MapCanvas.tsx:430-434` + +### Handout File Size Validation (MEDIUM) +- **W3-3**: Handout upload has no file size validation. Large files silently fail on localStorage quota. `handoutStore.ts:87`, `HandoutSection.tsx:36-42`. Compare with MapUpload.tsx which HAS 20MB validation. + +### Memory/Resource Leaks (MEDIUM) +- **W3-4**: MapCanvas `requestAnimationFrame` not cancelled on unmount. `MapCanvas.tsx:200` +- **W3-5**: Unbounded async in useEffect without abort — 3 components. `CampaignDashboard.tsx:139-165`, `CampaignOrganizer.tsx:100-127`, `PlayerViewPage.tsx:175-190` +- **W3-6**: `setTimeout` without cleanup — 2 components. `LootGenerator.tsx:212-215`, `DiceRoller.tsx:53-55` +- **W3-7**: Handout data URLs in localStorage — quota exhaustion risk. `handoutStore.ts:44-57,82` +- **W3-8**: SRD reference data permanently resident in memory (~500KB+). `SRDViewer.tsx:3-8,419` +- **W3-9**: IndexedDB exportCampaign opens N+1 separate transactions. `indexeddb.ts:532-561` + +### Test Coverage Gaps (MEDIUM) +- **W3-10**: 15 test files cover only math engine + 1 UI component. 0 tests for 9 stores, 747-line storage adapter, 30+ UI components. ~10% file coverage. +- **W3-11**: No integration tests — stores never tested with adapter. Zero data-flow verification. +- **W3-12**: E2E tests are smoke-only — verify page loads, no CRUD workflow testing. +- **W3-13**: No vitest coverage thresholds configured. No CI enforcement. + +--- + +## Verification Notes +- **Oracle-reviewed**: Two false positives corrected (HandoutStore Zustang detection, Stored XSS via innerHTML) +- **Wave 2 additions**: Race conditions, input validation, Python scripts, PWA, theme system, dead code, memory leaks, schema migration, test coverage +- **Wave 3 additions**: CombatMeta data loss, MapCanvas touch support, handout size validation, 6 memory/resource leaks, 4 test coverage gaps +- **197 existing tests**: All pass, but cover only the math engine (0 store/adapter/UI tests) +- **Total agents deployed**: 30 across 4 waves +- **Total unique bugs**: 65 (Wave 1-2) + 13 (Wave 3) + 57 (Wave 4) ≈ **135 bugs** + +### Wave 4 Additions (Oracle-identified coverage gaps) +- **Bundle bloat**: 15MB monolithic build, 12MB monsters-full.json imported 3× eagerly, zero code splitting +- **Calendar store**: `advanceDay` timezone/DST fragility (MEDIUM-HIGH) +- **Dice store**: `addRoll` can't associate with campaigns; `clearHistory` partial-failure inconsistency +- **Settings store**: `initialize` doesn't merge defaults; `resetSettings` skips init guard +- **UUID inconsistency**: 9 files use `uuidv4()`, 1 file uses `crypto.randomUUID()` — competing strategies +- **Tauri capabilities**: CRITICAL — no `capabilities/default.json`, all plugins dead at runtime +- **PWA triple-manifest**: static manifest, plugin manifest, HTML link — browser confusion +- **5e heavy armor DEX bug**: `Math.min(-1, 0) = -1` should be 0 (rules violation) +- **Encounter builder**: XP thresholds hardcoded for level 1; PF2e budget per-player vs party; PF2e uses 5e HP heuristics +- **Loot engine**: Individual treasure uses multiply instead of sum (wrong distribution); CR 0-4 art objects completely wrong; duplicate art objects CR 11-16 vs 17+ +- **ActionTracker**: PF2e action diamond click logic inverted (used actions call onUseAction instead of onUndoAction) +- **BestiaryList**: monster name as React key (non-unique) +- **20+ component-level bugs** across InitiativeTracker, CombatantCard, CampaignForm, RestControls, EncounterBuilder diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..c1cebdb --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +// Each test starts from a clean IndexedDB so runs are deterministic. +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('full core flow: campaign → character → dice → combat → compendium', async ({ page }) => { + // --- Campaign --- + await expect(page.getByRole('heading', { name: 'Campaigns' })).toBeVisible(); + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.getByLabel('').first().waitFor(); // modal open + await page.locator('input[data-autofocus]').fill('Curse of Strahd'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Card appears and becomes active + await expect(page.getByRole('heading', { name: 'Curse of Strahd' })).toBeVisible(); + + // --- Character --- + await page.getByRole('link', { name: 'Characters' }).click(); + await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible(); + await page.getByRole('button', { name: '+ New character' }).first().click(); + await page.locator('input[data-autofocus]').fill('Ireena'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: /Ireena/ }).click(); + + // On the sheet: set STR to 16 and expect +3 modifier + await page.getByLabel('STR score').fill('16'); + await expect(page.getByText('+3').first()).toBeVisible(); + + // --- Dice --- + await page.getByRole('link', { name: 'Dice' }).click(); + await page.getByRole('button', { name: 'Roll' }).click(); + // a result total renders (1..20 for default 1d20) + await expect(page.locator('text=/= \\d+/')).toBeVisible(); + + // --- Combat --- + await page.getByRole('link', { name: 'Combat' }).click(); + await page.getByRole('button', { name: '+ New encounter' }).first().click(); + await page.locator('input[data-autofocus]').fill('Ambush'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByLabel('Name').fill('Goblin'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(page.getByText('Goblin')).toBeVisible(); + await page.getByRole('button', { name: 'Start combat' }).click(); + await expect(page.getByText(/Round 1/)).toBeVisible(); + + // --- Compendium --- + await page.getByRole('link', { name: 'Compendium' }).click(); + await expect(page.getByText(/results/)).toBeVisible(); + await page.getByPlaceholder('Search monsters…').fill('goblin'); + await page.getByRole('button', { name: /^Goblin/ }).first().click(); + await expect(page.getByRole('heading', { name: 'Goblin', exact: true })).toBeVisible(); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3aa76c2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: 'line', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'bun run dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 60_000, + }, +}); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6e30136 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + 20 + diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..0af9b3f6ec380d477eb42b9d67c42214d7521ee4 GIT binary patch literal 9362 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 M07*qoM6N<$g4fg|E&u=k literal 0 HcmV?d00001 diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..8948101c22d670799676921d6fa6558be698f775 GIT binary patch literal 24605 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 literal 0 HcmV?d00001 diff --git a/scripts/fetch_data.py b/scripts/fetch_data.py new file mode 100644 index 0000000..fd0aae1 --- /dev/null +++ b/scripts/fetch_data.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Fetch comprehensive D&D 5e data from Open5e v1 API and PF2e from GitHub""" +import json, time, os, sys, requests +from urllib.parse import urljoin + +OUT_DIR = os.path.join(os.path.dirname(__file__), "src", "assets", "srd") +BASE_V1 = "https://api.open5e.com/v1/" + +def fetch_paginated(endpoint, max_pages=None): + """Fetch all pages from an Open5e v1 endpoint.""" + results = [] + url = urljoin(BASE_V1, f"{endpoint}/?format=json&limit=500") + page = 0 + while url: + page += 1 + print(f" Fetching {endpoint} page {page}... ({len(results)} items so far)") + resp = requests.get(url, headers={"User-Agent": "TTRPG-Manager/1.0"}, timeout=30) + if resp.status_code != 200: + print(f" Error: {resp.status_code}") + break + data = resp.json() + results.extend(data.get("results", [])) + url = data.get("next") + if max_pages and page >= max_pages: + break + time.sleep(0.3) + return results + +def fetch_all(): + os.makedirs(OUT_DIR, exist_ok=True) + + # Only fetch SRD/OGL content (document__slug = 'wotc-srd' or 'srd') + endpoints = { + "monsters": "monsters", + "spells": "spells", + "magicitems": "magicitems", + "weapons": "weapons", + } + + for name, endpoint in endpoints.items(): + print(f"\n=== Fetching {name} ===") + items = fetch_paginated(endpoint) + if not items: + print(f" No items found for {name}, trying alternate...") + continue + + # Filter to SRD content only + srd_items = [] + for item in items: + doc = item.get("document__slug", "") + if doc in ("wotc-srd", "srd", "wotc", "5e-srd", "o5e"): + srd_items.append(item) + + print(f" Total: {len(items)}, SRD: {len(srd_items)}") + + # Save full file + outpath = os.path.join(OUT_DIR, f"{name}-full.json") + with open(outpath, "w") as f: + json.dump(items, f, indent=2) + print(f" Saved {len(items)} to {outpath}") + + # Save SRD-only file + if srd_items and len(srd_items) < len(items): + outpath_srd = os.path.join(OUT_DIR, f"{name}-srd.json") + with open(outpath_srd, "w") as f: + json.dump(srd_items, f, indent=2) + print(f" Saved {len(srd_items)} SRD to {outpath_srd}") + +if __name__ == "__main__": + fetch_all() diff --git a/scripts/parse_mpmb.py b/scripts/parse_mpmb.py new file mode 100644 index 0000000..1499010 --- /dev/null +++ b/scripts/parse_mpmb.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Parse MPMB all_WotC_published.js into structured JSON files""" +import re, json, os + +INPUT = "/home/nilsb/Downloads/all_WotC_published.js" +OUT_DIR = "/home/nilsb/Documents/Projects/TTRPG_manager/src/assets/srd" + +def parse_mpmb(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all list assignments: ListName["key"] = { ... }; + # Strategy: extract each list type by finding assignment patterns + lists = {} + list_names = [ + "SourceList", "MagicItemsList", "SpellsList", "ClassList", + "RaceList", "FeatsList", "BackgroundList", "BackgroundFeatureList", + "CreatureList", "WeaponsList", "AmmoList", "GearList", "CompanionList" + ] + + for list_name in list_names: + entries = {} + # Pattern: ListName["key"] = { ... }; + # Match multi-line objects by counting braces + pattern = re.escape(list_name) + r'\["([^"]+)"\]\s*=\s*\{' + for match in re.finditer(pattern, content): + key = match.group(1) + start = match.end() - 1 # position of opening { + # Find matching closing brace + depth = 0 + i = start + in_string = False + string_char = None + while i < len(content): + c = content[i] + if in_string: + if c == '\\': + i += 2 + continue + if c == string_char: + in_string = False + i += 1 + continue + if c in ('"', "'"): + in_string = True + string_char = c + elif c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + obj_str = content[start:i+1] + break + elif c == '/' and i+1 < len(content) and content[i+1] in ('/', '*'): + # Skip comments + if content[i+1] == '/': + while i < len(content) and content[i] != '\n': + i += 1 + continue + elif content[i+1] == '*': + i += 2 + while i < len(content) and not (content[i] == '*' and content[i+1] == '/'): + i += 1 + i += 2 + continue + i += 1 + else: + continue # no closing brace found + + # Store raw object text (can't safely eval, but we can extract key fields) + entries[key] = obj_str + + if entries: + lists[list_name] = entries + print(f" {list_name}: {len(entries)} entries") + + return lists + +def safe_extract_fields(obj_str): + """Extract common fields from a JS object literal string.""" + fields = {} + patterns = { + 'name': r'name\s*:\s*["\']([^"\']+)["\']', + 'type': r'type\s*:\s*["\']([^"\']+)["\']', + 'rarity': r'rarity\s*:\s*["\']([^"\']+)["\']', + 'source': r'source\s*:\s*(\[\[.*?\]\])', + 'description': r'description\s*:\s*["\']([^"]+)["\']', + 'weight': r'weight\s*:\s*(-?\d+)', + 'prerequisite': r'prerequisite\s*:\s*["\']([^"\']+)["\']', + } + for field, pattern in patterns.items(): + m = re.search(pattern, obj_str) + if m: + val = m.group(1) + if field == 'weight': + val = int(val) + elif field == 'source': + # Parse [[SourceName, page], [SourceName2, page2]] + sources = re.findall(r'\["([^"]+)",?\s*(\d+)?\]', val) + val = [{"source": s[0], "page": int(s[1]) if s[1] else None} for s in sources] + fields[field] = val + return fields + +if __name__ == "__main__": + print("Parsing MPMB data file...") + lists = parse_mpmb(INPUT) + + os.makedirs(OUT_DIR, exist_ok=True) + + # Save raw entry counts + for name, entries in lists.items(): + # Extract key fields for each entry + extracted = {} + for key, obj_str in entries.items(): + extracted[key] = safe_extract_fields(obj_str) + + outname = f"mpmb-{name.lower().replace('list','')}.json" + outpath = os.path.join(OUT_DIR, outname) + with open(outpath, 'w') as f: + json.dump(extracted, f, indent=2) + print(f" Saved {outname}: {len(extracted)} entries") + + print("\nDone! Files saved to", OUT_DIR) diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx new file mode 100644 index 0000000..0de2f1c --- /dev/null +++ b/src/app/RootLayout.tsx @@ -0,0 +1,91 @@ +import { Link, Outlet, useRouterState } from '@tanstack/react-router'; +import { useUiStore } from '@/stores/uiStore'; +import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks'; +import { cn } from '@/lib/cn'; +import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { Select } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; + +const NAV = [ + { to: '/', label: 'Campaigns', exact: true }, + { to: '/characters', label: 'Characters', exact: false }, + { to: '/combat', label: 'Combat', exact: false }, + { to: '/dice', label: 'Dice', exact: false }, + { to: '/compendium', label: 'Compendium', exact: false }, +] as const; + +function CampaignSwitcher() { + const campaigns = useCampaigns(); + const active = useActiveCampaign(); + const setActive = useUiStore((s) => s.setActiveCampaign); + + if (campaigns.length === 0) { + return No campaigns yet; + } + return ( + + ); +} + +function ThemeToggle() { + const theme = useUiStore((s) => s.theme); + const toggle = useUiStore((s) => s.toggleTheme); + return ( + + ); +} + +export function RootLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( +
+
+ + ⚔ TTRPG Manager + + +
+ + +
+
+ +
+ + + +
+
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..95042c3 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,44 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { cn } from '@/lib/cn'; + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type Size = 'sm' | 'md' | 'icon'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; +} + +const variants: Record = { + primary: 'bg-accent text-accent-ink hover:opacity-90 font-medium', + secondary: 'bg-elevated text-ink hover:bg-line border border-line', + ghost: 'text-muted hover:text-ink hover:bg-elevated', + danger: 'bg-danger text-white hover:opacity-90 font-medium', +}; + +const sizes: Record = { + sm: 'h-8 px-3 text-sm rounded-md', + md: 'h-10 px-4 text-sm rounded-md', + icon: 'h-9 w-9 rounded-md grid place-items-center', +}; + +export const Button = forwardRef(function Button( + { className, variant = 'secondary', size = 'md', type, ...props }, + ref, +) { + return ( + + + ); + } + return this.props.children; + } +} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..e0389d3 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react'; +import { cn } from '@/lib/cn'; + +const base = + 'w-full bg-surface border border-line rounded-md px-3 py-2 text-sm text-ink placeholder:text-muted ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60'; + +export const Input = forwardRef>( + function Input({ className, ...props }, ref) { + return ; + }, +); + +export const Textarea = forwardRef>( + function Textarea({ className, ...props }, ref) { + return