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 0000000..0af9b3f Binary files /dev/null and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000..8948101 Binary files /dev/null and b/public/pwa-512x512.png differ 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