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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:09:42 +02:00
parent fe84dc365d
commit 1a9e5e2c18
93 changed files with 412052 additions and 9 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "bun",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+66
View File
@@ -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.
+264
View File
@@ -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 `<main>` 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
+59
View File
@@ -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();
});
+20
View File
@@ -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,
},
});
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="12" fill="#0f0f17"/>
<g transform="translate(32 33)" fill="none" stroke="#d4af37" stroke-width="2.4" stroke-linejoin="round">
<polygon points="0,-24 21,-11 21,12 0,25 -21,12 -21,-11" fill="#181826"/>
<polygon points="0,-24 13,-4 -13,-4" fill="#21212f"/>
<polygon points="13,-4 21,12 0,7 -21,12 -13,-4" />
<polygon points="0,7 0,25" />
<polygon points="13,-4 0,7 -13,-4" />
</g>
<text x="32" y="38" font-family="Georgia, serif" font-size="13" fill="#d4af37" text-anchor="middle" font-weight="bold">20</text>
</svg>

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+70
View File
@@ -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()
+123
View File
@@ -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)
+91
View File
@@ -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 <span className="text-xs text-muted">No campaigns yet</span>;
}
return (
<Select
aria-label="Active campaign"
className="w-auto min-w-44"
value={active?.id ?? ''}
onChange={(e) => setActive(e.target.value || null)}
>
<option value=""> Select campaign </option>
{campaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.system === '5e' ? 'D&D 5e' : 'PF2e'})
</option>
))}
</Select>
);
}
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
return (
<Button size="icon" variant="ghost" onClick={toggle} aria-label="Toggle light/dark theme" title="Toggle theme">
{theme === 'dark' ? '☀' : '☾'}
</Button>
);
}
export function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
<Link to="/" className="font-display text-lg font-bold text-accent">
TTRPG Manager
</Link>
<nav className="flex items-center gap-1" aria-label="Primary">
{NAV.map((item) => {
const isActive = item.exact ? pathname === item.to : pathname.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
isActive ? 'bg-elevated text-accent' : 'text-muted hover:text-ink hover:bg-elevated',
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<CampaignSwitcher />
<ThemeToggle />
</div>
</header>
<main className="flex-1 overflow-auto">
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
</main>
</div>
);
}
+44
View File
@@ -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<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const variants: Record<Variant, string> = {
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<Size, string> = {
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<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant = 'secondary', size = 'md', type, ...props },
ref,
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(
'inline-flex items-center justify-center gap-2 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
'disabled:opacity-50 disabled:pointer-events-none',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
});
+50
View File
@@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from './Button';
interface Props {
children: ReactNode;
/** reset key — when it changes, the boundary clears its error */
resetKey?: string | number;
}
interface State {
error: Error | null;
}
/** Catches render errors so one broken screen never white-screens the whole app. */
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Render error caught by boundary:', error, info.componentStack);
}
override componentDidUpdate(prev: Props): void {
if (prev.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
override render(): ReactNode {
if (this.state.error) {
return (
<div className="m-6 rounded-lg border border-danger/40 bg-danger/10 p-6">
<h2 className="text-lg font-semibold text-danger">Something went wrong</h2>
<p className="mt-1 text-sm text-muted">
This screen hit an error. Your data is safe try again or reload.
</p>
<pre className="mt-3 max-h-40 overflow-auto rounded bg-surface p-3 text-xs text-muted">
{this.state.error.message}
</pre>
<Button className="mt-4" variant="secondary" onClick={() => this.setState({ error: null })}>
Try again
</Button>
</div>
);
}
return this.props.children;
}
}
+33
View File
@@ -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<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(base, className)} {...props} />;
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...props }, ref) {
return <textarea ref={ref} className={cn(base, 'resize-y min-h-20', className)} {...props} />;
},
);
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, ...props }, ref) {
return <select ref={ref} className={cn(base, 'pr-8', className)} {...props} />;
},
);
export function Field({ label, children, htmlFor }: { label: string; children: React.ReactNode; htmlFor?: string }) {
return (
<label className="block space-y-1" htmlFor={htmlFor}>
<span className="text-xs font-medium text-muted uppercase tracking-wide">{label}</span>
{children}
</label>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { Button } from './Button';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
className?: string;
}
/** Accessible dialog: Escape to close, focus trapping, restores focus on close. */
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'Tab' && panel) {
const focusable = panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 grid place-items-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} aria-hidden />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
'relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-lg border border-line bg-panel shadow-2xl',
className,
)}
>
<div className="flex items-center justify-between border-b border-line px-5 py-3">
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
</Button>
</div>
<div className="px-5 py-4">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</div>}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { cn } from '@/lib/cn';
/** Integer input that never emits NaN — coerces blanks/garbage to a fallback. */
export function NumberField({
value,
onChange,
min,
max,
className,
'aria-label': ariaLabel,
}: {
value: number;
onChange: (n: number) => void;
min?: number;
max?: number;
className?: string;
'aria-label'?: string;
}) {
return (
<input
type="number"
inputMode="numeric"
aria-label={ariaLabel}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
onChange={(e) => {
const raw = Number(e.target.value);
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0;
if (min !== undefined) n = Math.max(min, n);
if (max !== undefined) n = Math.min(max, n);
onChange(n);
}}
className={cn(
'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
className,
)}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import type { Campaign } from '@/lib/schemas';
export function Page({ children }: { children: ReactNode }) {
return <div className="mx-auto max-w-6xl px-4 py-6">{children}</div>;
}
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-2xl font-bold text-ink">{title}</h1>
{subtitle && <p className="mt-0.5 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) {
return (
<div className="rounded-lg border border-dashed border-line bg-panel/50 p-10 text-center">
<p className="font-medium text-ink">{title}</p>
{hint && <p className="mt-1 text-sm text-muted">{hint}</p>}
{action && <div className="mt-4 flex justify-center">{action}</div>}
</div>
);
}
/** Gate a screen on an active campaign; renders a prompt if none is selected. */
export function RequireCampaign({ children }: { children: (campaign: Campaign) => ReactNode }) {
const campaign = useActiveCampaign();
if (!campaign) {
return (
<Page>
<EmptyState
title="No campaign selected"
hint="Pick a campaign from the switcher up top, or create one to get started."
action={
<Link to="/" className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-ink">
Go to Campaigns
</Link>
}
/>
</Page>
);
}
return <>{children(campaign)}</>;
}
+68
View File
@@ -0,0 +1,68 @@
[
{
"name": "Attack",
"description": "The most common action to take in combat is the Attack action, whether you are swinging a sword, firing an arrow from a bow, or brawling with your fists. With this action, you make one melee or ranged attack.",
"features": [
"Certain features, such as the Extra Attack feature of the fighter, allow you to make more than one attack with this action."
]
},
{
"name": "Cast a Spell",
"description": "Spellcasters such as wizards and clerics, as well as many monsters, have access to spells and can use them to great effect in combat. Each spell has a casting time, which specifies whether the caster must use an action, a reaction, minutes, or even hours to cast the spell.",
"features": [
"Casting a spell is, therefore, not necessarily an action. Most spells do have a casting time of 1 action, so a spellcaster often uses his or her action in combat to cast such a spell."
]
},
{
"name": "Dash",
"description": "When you take the Dash action, you gain extra movement for the current turn. The increase equals your speed, after applying any modifiers. With a speed of 30 feet, for example, you can move up to 60 feet on your turn if you dash.",
"features": [
"Any increase or decrease to your speed changes this additional movement by the same amount. If your speed of 30 feet is reduced to 15 feet, for instance, you can move up to 30 feet this turn if you dash."
]
},
{
"name": "Disengage",
"description": "If you take the Disengage action, your movement doesn't provoke opportunity attacks for the rest of the turn.",
"features": []
},
{
"name": "Dodge",
"description": "When you take the Dodge action, you focus entirely on avoiding attacks. Until the start of your next turn, any attack roll made against you has disadvantage if you can see the attacker, and you make Dexterity saving throws with advantage.",
"features": [
"You lose this benefit if you are incapacitated or if your speed drops to 0."
]
},
{
"name": "Help",
"description": "You can lend your aid to another creature in the completion of a task. When you take the Help action, the creature you aid gains advantage on the next ability check it makes to perform the task you are helping with, provided that it makes the check before the start of your next turn.",
"features": [
"Alternatively, you can aid a friendly creature in attacking a creature within 5 feet of you. You feint, distract the target, or in some other way team up to make your ally's attack more effective. If your ally attacks the target before your next turn, the first attack roll is made with advantage."
]
},
{
"name": "Hide",
"description": "When you take the Hide action, you make a Dexterity (Stealth) check in an attempt to hide, following the rules for hiding. If you succeed, you gain certain benefits, as described in the 'Unseen Attackers and Targets' section.",
"features": []
},
{
"name": "Ready",
"description": "Sometimes you want to get the jump on a foe or wait for a particular circumstance before you act. To do so, you can take the Ready action on your turn, which lets you act using your reaction before the start of your next turn.",
"features": [
"First, you decide what perceivable circumstance will trigger your reaction. Then, you choose the action you will take in response to that trigger, or you choose to move up to your speed in response to it.",
"When the trigger occurs, you can either take your reaction right after the trigger finishes or ignore the trigger. Remember that you can take only one reaction per round.",
"When you ready a spell, you cast it as normal but hold its energy, which you release with your reaction when the trigger occurs. To be readied, a spell must have a casting time of 1 action, and holding onto the spell's magic requires concentration."
]
},
{
"name": "Search",
"description": "When you take the Search action, you devote your attention to finding something. Depending on the nature of your search, the DM might have you make a Wisdom (Perception) check or an Intelligence (Investigation) check.",
"features": []
},
{
"name": "Use an Object",
"description": "You normally interact with an object while doing something else, such as when you draw a sword as part of an attack. When an object requires your action for its use, you take the Use an Object action.",
"features": [
"This action is also useful when you want to interact with more than one object on your turn."
]
}
]
+119
View File
@@ -0,0 +1,119 @@
[
{
"name": "Blinded",
"description": "A blinded creature can't see and automatically fails any ability check that requires sight.",
"effects": [
"Attack rolls against the creature have advantage, and the creature's attack rolls have disadvantage."
]
},
{
"name": "Charmed",
"description": "A charmed creature can't attack the charmer or target the charmer with harmful abilities or magical effects.",
"effects": [
"The charmer has advantage on any ability check to interact socially with the creature."
]
},
{
"name": "Deafened",
"description": "A deafened creature can't hear and automatically fails any ability check that requires hearing.",
"effects": []
},
{
"name": "Exhaustion",
"description": "Some special abilities and environmental hazards, such as starvation and the long-term effects of freezing or scorching temperatures, can lead to a special condition called exhaustion. Exhaustion is measured in six levels. An effect can give a creature one or more levels of exhaustion.",
"effects": [
"Level 1: Disadvantage on ability checks",
"Level 2: Speed halved",
"Level 3: Disadvantage on attack rolls and saving throws",
"Level 4: Hit point maximum halved",
"Level 5: Speed reduced to 0",
"Level 6: Death"
]
},
{
"name": "Frightened",
"description": "A frightened creature has disadvantage on ability checks and attack rolls while the source of its fear is within line of sight.",
"effects": [
"The creature can't willingly move closer to the source of its fear."
]
},
{
"name": "Grappled",
"description": "A grappled creature's speed becomes 0, and it can't benefit from any bonus to its speed.",
"effects": [
"The condition ends if the grappler is incapacitated.",
"The condition also ends if an effect removes the grappled creature from the reach of the grappler or grappling effect, such as when a creature is hurled away by the thunderwave spell."
]
},
{
"name": "Incapacitated",
"description": "An incapacitated creature can't take actions or reactions.",
"effects": []
},
{
"name": "Invisible",
"description": "An invisible creature is impossible to see without the aid of magic or a special sense. For the purpose of hiding, the creature is heavily obscured. The creature's location can be detected by any noise it makes or any tracks it leaves.",
"effects": [
"Attack rolls against the creature have disadvantage, and the creature's attack rolls have advantage."
]
},
{
"name": "Paralyzed",
"description": "A paralyzed creature is incapacitated and can't move or speak.",
"effects": [
"The creature automatically fails Strength and Dexterity saving throws.",
"Attack rolls against the creature have advantage.",
"Any attack that hits the creature is a critical hit if the attacker is within 5 feet of the creature."
]
},
{
"name": "Petrified",
"description": "A petrified creature is transformed, along with any nonmagical object it is wearing or carrying, into a solid inanimate substance (usually stone). Its weight increases by a factor of ten, and it ceases aging.",
"effects": [
"The creature is incapacitated, can't move or speak, and is unaware of its surroundings.",
"Attack rolls against the creature have advantage.",
"The creature automatically fails Strength and Dexterity saving throws.",
"The creature has resistance to all damage.",
"The creature is immune to poison and disease, although a poison or disease already in its system is suspended, not neutralized."
]
},
{
"name": "Poisoned",
"description": "A poisoned creature has disadvantage on attack rolls and ability checks.",
"effects": []
},
{
"name": "Prone",
"description": "A prone creature's only movement option is to crawl, unless it stands up and thereby ends the condition.",
"effects": [
"The creature has disadvantage on attack rolls.",
"An attack roll against the creature has advantage if the attacker is within 5 feet of the creature. Otherwise, the attack roll has disadvantage."
]
},
{
"name": "Restrained",
"description": "A restrained creature's speed becomes 0, and it can't benefit from any bonus to its speed.",
"effects": [
"Attack rolls against the creature have advantage, and the creature's attack rolls have disadvantage.",
"The creature has disadvantage on Dexterity saving throws."
]
},
{
"name": "Stunned",
"description": "A stunned creature is incapacitated, can't move, and can speak only falteringly.",
"effects": [
"The creature automatically fails Strength and Dexterity saving throws.",
"Attack rolls against the creature have advantage."
]
},
{
"name": "Unconscious",
"description": "An unconscious creature is incapacitated, can't move or speak, and is unaware of its surroundings.",
"effects": [
"The creature drops whatever it's holding and falls prone.",
"The creature automatically fails Strength and Dexterity saving throws.",
"Attack rolls against the creature have advantage.",
"Any attack that hits the creature is a critical hit if the attacker is within 5 feet of the creature."
]
}
]
+29
View File
@@ -0,0 +1,29 @@
{
"armor": [
{ "name": "Padded", "type": "Light", "ac": "11 + Dex", "dexCap": null, "stealthPenalty": true, "weight": 8, "cost": "5 gp" },
{ "name": "Leather", "type": "Light", "ac": "11 + Dex", "dexCap": null, "stealthPenalty": false, "weight": 10, "cost": "10 gp" },
{ "name": "Studded Leather", "type": "Light", "ac": "12 + Dex", "dexCap": null, "stealthPenalty": false, "weight": 13, "cost": "45 gp" },
{ "name": "Hide", "type": "Medium", "ac": "12 + Dex (max 2)", "dexCap": 2, "stealthPenalty": false, "weight": 12, "cost": "10 gp" },
{ "name": "Chain Shirt", "type": "Medium", "ac": "13 + Dex (max 2)", "dexCap": 2, "stealthPenalty": false, "weight": 20, "cost": "50 gp" },
{ "name": "Scale Mail", "type": "Medium", "ac": "14 + Dex (max 2)", "dexCap": 2, "stealthPenalty": true, "weight": 45, "cost": "50 gp" },
{ "name": "Breastplate", "type": "Medium", "ac": "14 + Dex (max 2)", "dexCap": 2, "stealthPenalty": false, "weight": 20, "cost": "400 gp" },
{ "name": "Half Plate", "type": "Medium", "ac": "15 + Dex (max 2)", "dexCap": 2, "stealthPenalty": true, "weight": 40, "cost": "750 gp" },
{ "name": "Ring Mail", "type": "Heavy", "ac": "14", "dexCap": 0, "stealthPenalty": true, "weight": 40, "cost": "30 gp" },
{ "name": "Chain Mail", "type": "Heavy", "ac": "16", "dexCap": 0, "stealthPenalty": true, "weight": 55, "cost": "75 gp", "minStr": 13 },
{ "name": "Splint", "type": "Heavy", "ac": "17", "dexCap": 0, "stealthPenalty": true, "weight": 60, "cost": "200 gp", "minStr": 15 },
{ "name": "Plate", "type": "Heavy", "ac": "18", "dexCap": 0, "stealthPenalty": true, "weight": 65, "cost": "1,500 gp", "minStr": 15 },
{ "name": "Shield", "type": "Shield", "ac": "+2", "dexCap": null, "stealthPenalty": false, "weight": 6, "cost": "10 gp" }
],
"weapons": [
{ "name": "Club", "type": "Simple Melee", "damage": "1d4", "damageType": "bludgeoning", "properties": "Light", "weight": 2, "cost": "1 sp" },
{ "name": "Dagger", "type": "Simple Melee", "damage": "1d4", "damageType": "piercing", "properties": "Finesse, light, thrown (20/60)", "weight": 1, "cost": "2 gp" },
{ "name": "Quarterstaff", "type": "Simple Melee", "damage": "1d6", "damageType": "bludgeoning", "properties": "Versatile (1d8)", "weight": 4, "cost": "2 sp" },
{ "name": "Shortbow", "type": "Simple Ranged", "damage": "1d6", "damageType": "piercing", "properties": "Ammunition (80/320), two-handed", "weight": 2, "cost": "25 gp" },
{ "name": "Longsword", "type": "Martial Melee", "damage": "1d8", "damageType": "slashing", "properties": "Versatile (1d10)", "weight": 3, "cost": "15 gp" },
{ "name": "Rapier", "type": "Martial Melee", "damage": "1d8", "damageType": "piercing", "properties": "Finesse", "weight": 2, "cost": "25 gp" },
{ "name": "Greataxe", "type": "Martial Melee", "damage": "1d12", "damageType": "slashing", "properties": "Heavy, two-handed", "weight": 7, "cost": "30 gp" },
{ "name": "Greatsword", "type": "Martial Melee", "damage": "2d6", "damageType": "slashing", "properties": "Heavy, two-handed", "weight": 6, "cost": "50 gp" },
{ "name": "Longbow", "type": "Martial Ranged", "damage": "1d8", "damageType": "piercing", "properties": "Ammunition (150/600), heavy, two-handed", "weight": 2, "cost": "50 gp" },
{ "name": "Light Crossbow", "type": "Simple Ranged", "damage": "1d8", "damageType": "piercing", "properties": "Ammunition (80/320), loading, two-handed", "weight": 5, "cost": "25 gp" }
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+592
View File
@@ -0,0 +1,592 @@
[
{
"name": "Goblin",
"size": "Small",
"type": "humanoid (goblinoid)",
"alignment": "neutral evil",
"ac": 15,
"acType": "leather armor, shield",
"hp": 7,
"hd": "2d6",
"speed": "30 ft.",
"abilities": { "str": 8, "dex": 14, "con": 10, "int": 10, "wis": 8, "cha": 8 },
"saves": {},
"skills": { "Stealth": 6 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 9",
"languages": "Common, Goblin",
"cr": "1/4",
"xp": 50,
"traits": [
{ "name": "Nimble Escape", "description": "The goblin can take the Disengage or Hide action as a bonus action on each of its turns." }
],
"actions": [
{ "name": "Scimitar", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 5 (1d6 + 2) slashing damage." },
{ "name": "Shortbow", "description": "Ranged Weapon Attack: +4 to hit, range 80/320 ft., one target. Hit: 5 (1d6 + 2) piercing damage." }
]
},
{
"name": "Kobold",
"size": "Small",
"type": "humanoid (kobold)",
"alignment": "lawful evil",
"ac": 12,
"acType": "",
"hp": 5,
"hd": "2d6 - 2",
"speed": "30 ft.",
"abilities": { "str": 7, "dex": 15, "con": 9, "int": 8, "wis": 7, "cha": 8 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 8",
"languages": "Common, Draconic",
"cr": "1/8",
"xp": 25,
"traits": [
{ "name": "Sunlight Sensitivity", "description": "While in sunlight, the kobold has disadvantage on attack rolls, as well as on Wisdom (Perception) checks that rely on sight." },
{ "name": "Pack Tactics", "description": "The kobold has advantage on an attack roll against a creature if at least one of the kobold's allies is within 5 feet of the creature and the ally isn't incapacitated." }
],
"actions": [
{ "name": "Dagger", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 4 (1d4 + 2) piercing damage." },
{ "name": "Sling", "description": "Ranged Weapon Attack: +4 to hit, range 30/120 ft., one target. Hit: 4 (1d4 + 2) bludgeoning damage." }
]
},
{
"name": "Orc",
"size": "Medium",
"type": "humanoid (orc)",
"alignment": "chaotic evil",
"ac": 13,
"acType": "hide armor",
"hp": 15,
"hd": "2d8 + 6",
"speed": "30 ft.",
"abilities": { "str": 16, "dex": 12, "con": 16, "int": 7, "wis": 11, "cha": 10 },
"saves": {},
"skills": { "Intimidation": 2 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 10",
"languages": "Common, Orc",
"cr": "1/2",
"xp": 100,
"traits": [
{ "name": "Aggressive", "description": "As a bonus action, the orc can move up to its speed toward a hostile creature that it can see." }
],
"actions": [
{ "name": "Greataxe", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 9 (1d12 + 3) slashing damage." },
{ "name": "Javelin", "description": "Melee or Ranged Weapon Attack: +5 to hit, reach 5 ft. or range 30/120 ft., one target. Hit: 6 (1d6 + 3) piercing damage." }
]
},
{
"name": "Skeleton",
"size": "Medium",
"type": "undead",
"alignment": "lawful evil",
"ac": 13,
"acType": "armor scraps",
"hp": 13,
"hd": "2d8 + 4",
"speed": "30 ft.",
"abilities": { "str": 10, "dex": 14, "con": 15, "int": 6, "wis": 8, "cha": 5 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "bludgeoning",
"immunities": "poison",
"conditionImmunities": "exhaustion, poisoned",
"senses": "darkvision 60 ft., passive Perception 9",
"languages": "understands all languages it knew in life but can't speak",
"cr": "1/4",
"xp": 50,
"traits": [],
"actions": [
{ "name": "Shortsword", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 5 (1d6 + 2) piercing damage." },
{ "name": "Shortbow", "description": "Ranged Weapon Attack: +4 to hit, range 80/320 ft., one target. Hit: 5 (1d6 + 2) piercing damage." }
]
},
{
"name": "Zombie",
"size": "Medium",
"type": "undead",
"alignment": "neutral evil",
"ac": 8,
"acType": "",
"hp": 22,
"hd": "3d8 + 9",
"speed": "20 ft.",
"abilities": { "str": 13, "dex": 6, "con": 16, "int": 3, "wis": 6, "cha": 5 },
"saves": { "Wis": 0 },
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "poison",
"conditionImmunities": "poisoned",
"senses": "darkvision 60 ft., passive Perception 8",
"languages": "understands all languages it knew in life but can't speak",
"cr": "1/4",
"xp": 50,
"traits": [
{ "name": "Undead Fortitude", "description": "If damage reduces the zombie to 0 hit points, it must make a Constitution saving throw with a DC of 5 + the damage taken, unless the damage is radiant or from a critical hit. On a success, the zombie drops to 1 hit point instead." }
],
"actions": [
{ "name": "Slam", "description": "Melee Weapon Attack: +3 to hit, reach 5 ft., one target. Hit: 4 (1d6 + 1) bludgeoning damage." }
]
},
{
"name": "Giant Spider",
"size": "Large",
"type": "beast",
"alignment": "unaligned",
"ac": 14,
"acType": "natural armor",
"hp": 26,
"hd": "4d10 + 4",
"speed": "30 ft., climb 30 ft.",
"abilities": { "str": 14, "dex": 16, "con": 12, "int": 2, "wis": 11, "cha": 4 },
"saves": {},
"skills": { "Stealth": 7 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "blindsight 10 ft., darkvision 60 ft., passive Perception 10",
"languages": "—",
"cr": "1",
"xp": 200,
"traits": [
{ "name": "Spider Climb", "description": "The spider can climb difficult surfaces, including upside down on ceilings, without needing to make an ability check." },
{ "name": "Web Sense", "description": "While in contact with a web, the spider knows the exact location of any other creature in contact with the same web." },
{ "name": "Web Walker", "description": "The spider ignores movement restrictions caused by webbing." }
],
"actions": [
{ "name": "Bite", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one creature. Hit: 7 (1d8 + 3) piercing damage, and the target must make a DC 11 Constitution saving throw, taking 9 (2d8) poison damage on a failed save, or half as much damage on a successful one. If the poison damage reduces the target to 0 hit points, the target is stable but poisoned for 1 hour, even after regaining hit points, and is paralyzed while poisoned in this way." },
{ "name": "Web (Recharge 5-6)", "description": "Ranged Weapon Attack: +5 to hit, range 30/60 ft., one creature. Hit: The target is restrained by webbing. As an action, the restrained target can make a DC 12 Strength check, bursting the webbing on a success. The webbing can also be attacked and destroyed (AC 10; hp 5; vulnerability to fire damage; immunity to bludgeoning, poison, and psychic damage)." }
]
},
{
"name": "Owlbear",
"size": "Large",
"type": "monstrosity",
"alignment": "unaligned",
"ac": 13,
"acType": "natural armor",
"hp": 59,
"hd": "7d10 + 21",
"speed": "40 ft.",
"abilities": { "str": 20, "dex": 12, "con": 17, "int": 3, "wis": 12, "cha": 7 },
"saves": {},
"skills": { "Perception": 3 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 13",
"languages": "—",
"cr": "3",
"xp": 700,
"traits": [
{ "name": "Keen Sight and Smell", "description": "The owlbear has advantage on Wisdom (Perception) checks that rely on sight or smell." }
],
"actions": [
{ "name": "Multiattack", "description": "The owlbear makes two attacks: one with its beak and one with its claws." },
{ "name": "Beak", "description": "Melee Weapon Attack: +7 to hit, reach 5 ft., one creature. Hit: 10 (1d10 + 5) piercing damage." },
{ "name": "Claws", "description": "Melee Weapon Attack: +7 to hit, reach 5 ft., one target. Hit: 14 (2d8 + 5) slashing damage." }
]
},
{
"name": "Bugbear",
"size": "Medium",
"type": "humanoid (goblinoid)",
"alignment": "chaotic evil",
"ac": 16,
"acType": "hide armor, shield",
"hp": 27,
"hd": "5d8 + 5",
"speed": "30 ft.",
"abilities": { "str": 15, "dex": 14, "con": 13, "int": 8, "wis": 11, "cha": 9 },
"saves": {},
"skills": { "Stealth": 6, "Survival": 2 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 10",
"languages": "Common, Goblin",
"cr": "1",
"xp": 200,
"traits": [
{ "name": "Brute", "description": "A melee weapon deals one extra die of its damage when the bugbear hits with it (included in the attack)." },
{ "name": "Surprise Attack", "description": "If the bugbear surprises a creature and hits it with an attack during the first round of combat, the target takes an extra 7 (2d6) damage from the attack." }
],
"actions": [
{ "name": "Morningstar", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 11 (2d8 + 2) piercing damage." },
{ "name": "Javelin", "description": "Melee or Ranged Weapon Attack: +4 to hit, reach 5 ft. or range 30/120 ft., one target. Hit: 9 (2d6 + 2) piercing damage in melee, or 5 (1d6 + 2) piercing damage at range." }
]
},
{
"name": "Hobgoblin",
"size": "Medium",
"type": "humanoid (goblinoid)",
"alignment": "lawful evil",
"ac": 18,
"acType": "chain mail, shield",
"hp": 11,
"hd": "2d8 + 2",
"speed": "30 ft.",
"abilities": { "str": 13, "dex": 12, "con": 12, "int": 10, "wis": 10, "cha": 9 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 10",
"languages": "Common, Goblin",
"cr": "1/2",
"xp": 100,
"traits": [
{ "name": "Martial Advantage", "description": "Once per turn, the hobgoblin can deal an extra 7 (2d6) damage to a creature it hits with a weapon attack if that creature is within 5 feet of an ally of the hobgoblin that isn't incapacitated." }
],
"actions": [
{ "name": "Longsword", "description": "Melee Weapon Attack: +3 to hit, reach 5 ft., one target. Hit: 5 (1d8 + 1) slashing damage, or 6 (1d10 + 1) slashing damage if used with two hands." },
{ "name": "Longbow", "description": "Ranged Weapon Attack: +3 to hit, range 150/600 ft., one target. Hit: 5 (1d8 + 1) piercing damage." }
]
},
{
"name": "Ogre",
"size": "Large",
"type": "giant",
"alignment": "chaotic evil",
"ac": 11,
"acType": "hide armor",
"hp": 59,
"hd": "7d10 + 21",
"speed": "40 ft.",
"abilities": { "str": 19, "dex": 8, "con": 16, "int": 5, "wis": 7, "cha": 7 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 8",
"languages": "Common, Giant",
"cr": "2",
"xp": 450,
"traits": [],
"actions": [
{ "name": "Greatclub", "description": "Melee Weapon Attack: +6 to hit, reach 5 ft., one target. Hit: 13 (2d8 + 4) bludgeoning damage." },
{ "name": "Javelin", "description": "Melee or Ranged Weapon Attack: +6 to hit, reach 5 ft. or range 30/120 ft., one target. Hit: 11 (2d6 + 4) piercing damage." }
]
},
{
"name": "Ghoul",
"size": "Medium",
"type": "undead",
"alignment": "chaotic evil",
"ac": 12,
"acType": "",
"hp": 22,
"hd": "5d8",
"speed": "30 ft.",
"abilities": { "str": 13, "dex": 15, "con": 10, "int": 7, "wis": 10, "cha": 6 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "poison",
"conditionImmunities": "charmed, exhaustion, poisoned",
"senses": "darkvision 60 ft., passive Perception 10",
"languages": "Common",
"cr": "1",
"xp": 200,
"traits": [],
"actions": [
{ "name": "Bite", "description": "Melee Weapon Attack: +2 to hit, reach 5 ft., one creature. Hit: 9 (2d6 + 2) piercing damage." },
{ "name": "Claws", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 7 (2d4 + 2) slashing damage. If the target is a creature other than an elf or undead, it must succeed on a DC 10 Constitution saving throw or be paralyzed for 1 minute. The target can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success." }
]
},
{
"name": "Wight",
"size": "Medium",
"type": "undead",
"alignment": "neutral evil",
"ac": 14,
"acType": "studded leather",
"hp": 45,
"hd": "6d8 + 18",
"speed": "30 ft.",
"abilities": { "str": 15, "dex": 14, "con": 16, "int": 10, "wis": 13, "cha": 15 },
"saves": {},
"skills": { "Perception": 3, "Stealth": 4 },
"resistances": "necrotic; bludgeoning, piercing, and slashing from nonmagical attacks not made with silvered weapons",
"vulnerabilities": "",
"immunities": "poison",
"conditionImmunities": "exhaustion, poisoned",
"senses": "darkvision 60 ft., passive Perception 13",
"languages": "the languages it knew in life",
"cr": "3",
"xp": 700,
"traits": [
{ "name": "Sunlight Sensitivity", "description": "While in sunlight, the wight has disadvantage on attack rolls, as well as on Wisdom (Perception) checks that rely on sight." }
],
"actions": [
{ "name": "Multiattack", "description": "The wight makes two longsword attacks or two longbow attacks. It can use its Life Drain in place of one longsword attack." },
{ "name": "Life Drain", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one creature. Hit: 5 (1d6 + 2) necrotic damage. The target must succeed on a DC 13 Constitution saving throw or its hit point maximum is reduced by an amount equal to the damage taken. This reduction lasts until the target finishes a long rest. The target dies if this effect reduces its hit point maximum to 0." },
{ "name": "Longsword", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 6 (1d8 + 2) slashing damage, or 7 (1d10 + 2) slashing damage if used with two hands." },
{ "name": "Longbow", "description": "Ranged Weapon Attack: +4 to hit, range 150/600 ft., one target. Hit: 6 (1d8 + 2) piercing damage." }
]
},
{
"name": "Mimic",
"size": "Medium",
"type": "monstrosity (shapechanger)",
"alignment": "neutral",
"ac": 12,
"acType": "natural armor",
"hp": 58,
"hd": "9d8 + 18",
"speed": "15 ft.",
"abilities": { "str": 17, "dex": 12, "con": 15, "int": 5, "wis": 13, "cha": 8 },
"saves": {},
"skills": { "Stealth": 5 },
"resistances": "",
"vulnerabilities": "",
"immunities": "acid",
"conditionImmunities": "prone",
"senses": "darkvision 60 ft., passive Perception 11",
"languages": "—",
"cr": "2",
"xp": 450,
"traits": [
{ "name": "Shapechanger", "description": "The mimic can use its action to polymorph into an object or back into its true, amorphous form. Its statistics are the same in each form. Any equipment it is wearing or carrying isn't transformed. It reverts to its true form if it dies." },
{ "name": "Adhesive (Object Form Only)", "description": "The mimic adheres to anything that touches it. A Huge or smaller creature adhered to the mimic is also grappled by it (escape DC 13). Ability checks made to escape this grapple have disadvantage." },
{ "name": "False Appearance (Object Form Only)", "description": "While the mimic remains motionless, it is indistinguishable from an ordinary object." },
{ "name": "Grappler", "description": "The mimic has advantage on attack rolls against any creature grappled by it." }
],
"actions": [
{ "name": "Pseudopod", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 7 (1d8 + 3) bludgeoning damage. If the mimic is in object form, the target is subjected to its Adhesive trait." },
{ "name": "Bite", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 7 (1d8 + 3) piercing damage plus 4 (1d8) acid damage." }
]
},
{
"name": "Griffon",
"size": "Large",
"type": "monstrosity",
"alignment": "unaligned",
"ac": 12,
"acType": "",
"hp": 59,
"hd": "7d10 + 21",
"speed": "30 ft., fly 80 ft.",
"abilities": { "str": 18, "dex": 15, "con": 16, "int": 2, "wis": 13, "cha": 8 },
"saves": {},
"skills": { "Perception": 5 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 15",
"languages": "—",
"cr": "2",
"xp": 450,
"traits": [
{ "name": "Keen Sight", "description": "The griffon has advantage on Wisdom (Perception) checks that rely on sight." }
],
"actions": [
{ "name": "Multiattack", "description": "The griffon makes two attacks: one with its beak and one with its claws." },
{ "name": "Beak", "description": "Melee Weapon Attack: +6 to hit, reach 5 ft., one target. Hit: 8 (1d8 + 4) piercing damage." },
{ "name": "Claws", "description": "Melee Weapon Attack: +6 to hit, reach 5 ft., one target. Hit: 11 (2d6 + 4) slashing damage." }
]
},
{
"name": "Hippogriff",
"size": "Large",
"type": "monstrosity",
"alignment": "unaligned",
"ac": 11,
"acType": "",
"hp": 19,
"hd": "3d10 + 3",
"speed": "40 ft., fly 60 ft.",
"abilities": { "str": 17, "dex": 13, "con": 13, "int": 2, "wis": 12, "cha": 8 },
"saves": {},
"skills": { "Perception": 5 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "passive Perception 15",
"languages": "—",
"cr": "1",
"xp": 200,
"traits": [
{ "name": "Keen Sight", "description": "The hippogriff has advantage on Wisdom (Perception) checks that rely on sight." }
],
"actions": [
{ "name": "Multiattack", "description": "The hippogriff makes two attacks: one with its beak and one with its claws." },
{ "name": "Beak", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 8 (1d10 + 3) piercing damage." },
{ "name": "Claws", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 10 (2d6 + 3) slashing damage." }
]
},
{
"name": "Giant Eagle",
"size": "Large",
"type": "beast",
"alignment": "neutral good",
"ac": 13,
"acType": "",
"hp": 26,
"hd": "4d10 + 4",
"speed": "10 ft., fly 80 ft.",
"abilities": { "str": 16, "dex": 17, "con": 13, "int": 8, "wis": 14, "cha": 10 },
"saves": {},
"skills": { "Perception": 4 },
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "passive Perception 14",
"languages": "Giant Eagle, understands Common and Auran but can't speak them",
"cr": "1",
"xp": 200,
"traits": [
{ "name": "Keen Sight", "description": "The eagle has advantage on Wisdom (Perception) checks that rely on sight." }
],
"actions": [
{ "name": "Multiattack", "description": "The eagle makes two attacks: one with its beak and one with its talons." },
{ "name": "Beak", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 6 (1d6 + 3) piercing damage." },
{ "name": "Talons", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 10 (2d6 + 3) slashing damage." }
]
},
{
"name": "Animated Armor",
"size": "Medium",
"type": "construct",
"alignment": "unaligned",
"ac": 18,
"acType": "natural armor",
"hp": 33,
"hd": "6d8 + 6",
"speed": "25 ft.",
"abilities": { "str": 14, "dex": 11, "con": 13, "int": 1, "wis": 3, "cha": 1 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "poison, psychic",
"conditionImmunities": "blinded, charmed, deafened, exhaustion, frightened, paralyzed, petrified, poisoned",
"senses": "blindsight 60 ft. (blind beyond this radius), passive Perception 6",
"languages": "—",
"cr": "1",
"xp": 200,
"traits": [
{ "name": "Antimagic Susceptibility", "description": "The armor is incapacitated while in the area of an antimagic field. If targeted by dispel magic, the armor must succeed on a Constitution saving throw against the caster's spell save DC or fall unconscious for 1 minute." },
{ "name": "False Appearance", "description": "While the armor remains motionless, it is indistinguishable from a normal suit of armor." }
],
"actions": [
{ "name": "Multiattack", "description": "The armor makes two melee attacks." },
{ "name": "Slam", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 5 (1d6 + 2) bludgeoning damage." }
]
},
{
"name": "Basilisk",
"size": "Medium",
"type": "monstrosity",
"alignment": "unaligned",
"ac": 15,
"acType": "natural armor",
"hp": 52,
"hd": "8d8 + 16",
"speed": "20 ft.",
"abilities": { "str": 16, "dex": 8, "con": 15, "int": 2, "wis": 8, "cha": 7 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "",
"conditionImmunities": "",
"senses": "darkvision 60 ft., passive Perception 9",
"languages": "—",
"cr": "3",
"xp": 700,
"traits": [
{ "name": "Petrifying Gaze", "description": "If a creature starts its turn within 30 feet of the basilisk and the two of them can see each other, the basilisk can force the creature to make a DC 12 Constitution saving throw if the basilisk isn't incapacitated. On a failed save, the creature magically begins to turn to stone and is restrained. It must repeat the saving throw at the end of its next turn. On a success, the effect ends. On a failure, the creature is petrified until freed by the greater restoration spell or other magic." }
],
"actions": [
{ "name": "Bite", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one target. Hit: 10 (2d6 + 3) piercing damage plus 7 (2d6) poison damage." }
]
},
{
"name": "Ochre Jelly",
"size": "Large",
"type": "ooze",
"alignment": "unaligned",
"ac": 8,
"acType": "",
"hp": 45,
"hd": "6d10 + 12",
"speed": "10 ft., climb 10 ft.",
"abilities": { "str": 15, "dex": 6, "con": 14, "int": 2, "wis": 6, "cha": 1 },
"saves": {},
"skills": {},
"resistances": "acid",
"vulnerabilities": "",
"immunities": "lightning, slashing",
"conditionImmunities": "blinded, charmed, deafened, exhaustion, frightened, prone",
"senses": "blindsight 60 ft. (blind beyond this radius), passive Perception 8",
"languages": "—",
"cr": "2",
"xp": 450,
"traits": [
{ "name": "Amorphous", "description": "The jelly can move through a space as narrow as 1 inch wide without squeezing." },
{ "name": "Spider Climb", "description": "The jelly can climb difficult surfaces, including upside down on ceilings, without needing to make an ability check." }
],
"actions": [
{ "name": "Pseudopod", "description": "Melee Weapon Attack: +4 to hit, reach 5 ft., one target. Hit: 9 (2d6 + 2) bludgeoning damage plus 3 (1d6) acid damage." }
],
"reactions": [
{ "name": "Split", "description": "When a jelly that is Medium or larger is subjected to lightning or slashing damage, it splits into two new jellies if it has at least 10 hit points. Each new jelly has hit points equal to half the original jelly's, rounded down. New jellies are one size smaller than the original jelly." }
]
},
{
"name": "Rug of Smothering",
"size": "Large",
"type": "construct",
"alignment": "unaligned",
"ac": 12,
"acType": "",
"hp": 33,
"hd": "6d10",
"speed": "10 ft.",
"abilities": { "str": 17, "dex": 14, "con": 10, "int": 1, "wis": 3, "cha": 1 },
"saves": {},
"skills": {},
"resistances": "",
"vulnerabilities": "",
"immunities": "poison, psychic",
"conditionImmunities": "blinded, charmed, deafened, frightened, paralyzed, petrified, poisoned",
"senses": "blindsight 60 ft. (blind beyond this radius), passive Perception 6",
"languages": "—",
"cr": "2",
"xp": 450,
"traits": [
{ "name": "Antimagic Susceptibility", "description": "The rug is incapacitated while in the area of an antimagic field. If targeted by dispel magic, the rug must succeed on a Constitution saving throw against the caster's spell save DC or fall unconscious for 1 minute." },
{ "name": "Damage Transfer", "description": "While it is grappling a creature, the rug takes only half the damage dealt to it, and the creature grappled by the rug takes the other half." },
{ "name": "False Appearance", "description": "While the rug remains motionless, it is indistinguishable from a normal rug." }
],
"actions": [
{ "name": "Smother", "description": "Melee Weapon Attack: +5 to hit, reach 5 ft., one Medium or smaller creature. Hit: The creature is grappled (escape DC 13). Until this grapple ends, the target is restrained, blinded, and at risk of suffocating, and the rug can't smother another target. In addition, at the start of each of the target's turns, the target takes 10 (2d6 + 3) bludgeoning damage." }
]
}
]
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
{
"renaissance bullet": {
"name": "Bullets, Renaissance",
"source": [
{
"source": "D",
"page": 268
}
],
"weight": 0
},
"modern bullet": {
"name": "Bullets, Modern",
"source": [
{
"source": "D",
"page": 268
}
],
"weight": 0
},
"energy cell": {
"name": "Energy Cell",
"source": [
{
"source": "D",
"page": 268
},
{
"source": "QftIS",
"page": 192
}
],
"weight": 0
},
"bomb": {
"name": "Bomb",
"source": [
{
"source": "D",
"page": 267
}
],
"weight": 1
},
"dynamite stick": {
"name": "Dynamite stick",
"source": [
{
"source": "D",
"page": 268
}
],
"weight": 1
},
"grenade": {
"name": "Grenade",
"source": [
{
"source": "D",
"page": 268
}
],
"weight": 1
},
"oversized arrow": {
"name": "Oversized Arrows",
"source": [
{
"source": "WDH",
"page": 201
}
],
"weight": 0
}
}
+874
View File
@@ -0,0 +1,874 @@
{
"charlatan": {
"name": "Charlatan",
"source": [
{
"source": "P",
"page": 128
},
{
"source": "ALbackground",
"page": 0
}
]
},
"criminal": {
"name": "Criminal",
"source": [
{
"source": "P",
"page": 129
},
{
"source": "ALbackground",
"page": 0
}
]
},
"entertainer": {
"name": "Entertainer",
"source": [
{
"source": "P",
"page": 130
},
{
"source": "ALbackground",
"page": 0
}
]
},
"folk hero": {
"name": "Folk Hero",
"source": [
{
"source": "P",
"page": 131
},
{
"source": "ALbackground",
"page": 0
}
]
},
"guild artisan": {
"name": "Guild Artisan",
"source": [
{
"source": "P",
"page": 132
},
{
"source": "ALbackground",
"page": 0
}
]
},
"hermit": {
"name": "Hermit",
"source": [
{
"source": "P",
"page": 134
},
{
"source": "ALbackground",
"page": 0
}
]
},
"noble": {
"name": "Noble",
"source": [
{
"source": "P",
"page": 135
},
{
"source": "ALbackground",
"page": 0
}
]
},
"outlander": {
"name": "Outlander",
"source": [
{
"source": "P",
"page": 136
},
{
"source": "ALbackground",
"page": 0
}
]
},
"sage": {
"name": "Sage",
"source": [
{
"source": "P",
"page": 137
},
{
"source": "ALbackground",
"page": 0
}
]
},
"sailor": {
"name": "Sailor",
"source": [
{
"source": "P",
"page": 139
},
{
"source": "ALbackground",
"page": 0
}
]
},
"soldier": {
"name": "Soldier",
"source": [
{
"source": "P",
"page": 140
},
{
"source": "ALbackground",
"page": 0
}
]
},
"urchin": {
"name": "Urchin",
"source": [
{
"source": "P",
"page": 141
},
{
"source": "ALbackground",
"page": 0
}
]
},
"caravan specialist": {
"name": "Caravan Specialist",
"source": [
{
"source": "AL:EE",
"page": 2
},
{
"source": "ALbackground",
"page": 0
}
]
},
"earthspur miner": {
"name": "Earthspur Miner",
"source": [
{
"source": "AL:EE",
"page": 3
},
{
"source": "ALbackground",
"page": 0
}
]
},
"harborfolk": {
"name": "Harborfolk",
"source": [
{
"source": "AL:EE",
"page": 4
},
{
"source": "ALbackground",
"page": 0
}
]
},
"mulmaster aristocrat": {
"name": "Mulmaster Aristocrat",
"source": [
{
"source": "AL:EE",
"page": 5
},
{
"source": "ALbackground",
"page": 0
}
]
},
"phlan refugee": {
"name": "Phlan Refugee",
"source": [
{
"source": "AL:EE",
"page": 6
},
{
"source": "ALbackground",
"page": 0
}
]
},
"cormanthor refugee": {
"name": "Cormanthor Refugee",
"source": [
{
"source": "AL:RoD",
"page": 5
},
{
"source": "ALbackground",
"page": 0
}
]
},
"gate urchin": {
"name": "Gate Urchin",
"source": [
{
"source": "AL:RoD",
"page": 6
},
{
"source": "ALbackground",
"page": 0
}
]
},
"hillsfar merchant": {
"name": "Hillsfar Merchant",
"source": [
{
"source": "AL:RoD",
"page": 7
},
{
"source": "ALbackground",
"page": 0
}
]
},
"hillsfar smuggler": {
"name": "Hillsfar Smuggler",
"source": [
{
"source": "AL:RoD",
"page": 8
},
{
"source": "ALbackground",
"page": 0
}
]
},
"secret identity": {
"name": "Secret Identity",
"source": [
{
"source": "AL:RoD",
"page": 9
},
{
"source": "ALbackground",
"page": 0
}
]
},
"shade fanatic": {
"name": "Shade Fanatic",
"source": [
{
"source": "AL:RoD",
"page": 10
},
{
"source": "ALbackground",
"page": 0
}
]
},
"trade sheriff": {
"name": "Trade Sheriff",
"source": [
{
"source": "AL:RoD",
"page": 11
},
{
"source": "ALbackground",
"page": 0
}
]
},
"far traveler": {
"name": "Far Traveler",
"source": [
{
"source": "S",
"page": 148
},
{
"source": "ALbackground",
"page": 0
}
]
},
"haunted one": {
"name": "Haunted One",
"source": [
{
"source": "CoS",
"page": 209
},
{
"source": "VRGtR",
"page": 34
},
{
"source": "ALbackground",
"page": 0
}
]
},
"black fist double agent": {
"name": "Black Fist Double Agent",
"source": [
{
"source": "AL:CoS",
"page": 2
},
{
"source": "ALbackground",
"page": 0
}
]
},
"dragon casualty": {
"name": "Dragon Casualty",
"source": [
{
"source": "AL:CoS",
"page": 3
},
{
"source": "ALbackground",
"page": 0
}
]
},
"iron route bandit": {
"name": "Iron Route Bandit",
"source": [
{
"source": "AL:CoS",
"page": 5
},
{
"source": "ALbackground",
"page": 0
}
]
},
"phlan insurgent": {
"name": "Phlan Insurgent",
"source": [
{
"source": "AL:CoS",
"page": 6
},
{
"source": "ALbackground",
"page": 0
}
]
},
"stojanow prisoner": {
"name": "Stojanow Prisoner",
"source": [
{
"source": "AL:CoS",
"page": 8
},
{
"source": "ALbackground",
"page": 0
}
]
},
"ticklebelly nomad": {
"name": "Ticklebelly Nomad",
"source": [
{
"source": "AL:CoS",
"page": 9
},
{
"source": "ALbackground",
"page": 0
}
]
},
"anthropologist": {
"name": "Anthropologist",
"source": [
{
"source": "ToA",
"page": 191
},
{
"source": "ALbackground",
"page": 0
}
]
},
"archaeologist": {
"name": "Archaeologist",
"source": [
{
"source": "ToA",
"page": 192
},
{
"source": "ALbackground",
"page": 0
}
]
},
"azorius functionary": {
"name": "Azorius Functionary",
"source": [
{
"source": "G",
"page": 33
}
]
},
"boros legionnaire": {
"name": "Boros Legionnaire",
"source": [
{
"source": "G",
"page": 40
}
]
},
"dimir operative": {
"name": "Dimir Operative",
"source": [
{
"source": "G",
"page": 46
}
]
},
"golgari agent": {
"name": "Golgari Agent",
"source": [
{
"source": "G",
"page": 53
}
]
},
"gruul anarch": {
"name": "Gruul Anarch",
"source": [
{
"source": "G",
"page": 60
}
]
},
"izzet engineer": {
"name": "Izzet Engineer",
"source": [
{
"source": "G",
"page": 66
}
]
},
"orzhov representative": {
"name": "Orzhov Representative",
"source": [
{
"source": "G",
"page": 72
}
]
},
"rakdos cultist": {
"name": "Rakdos Cultist",
"source": [
{
"source": "G",
"page": 79
}
]
},
"selesnya initiate": {
"name": "Selesnya Initiate",
"source": [
{
"source": "G",
"page": 86
}
]
},
"simic scientist": {
"name": "Simic Scientist",
"source": [
{
"source": "G",
"page": 93
}
]
},
"fisher": {
"name": "Fisher",
"source": [
{
"source": "GoS",
"page": 29
},
{
"source": "ALbackground",
"page": 0
}
]
},
"marine": {
"name": "Marine",
"source": [
{
"source": "GoS",
"page": 31
},
{
"source": "ALbackground",
"page": 0
}
]
},
"shipwright": {
"name": "Shipwright",
"source": [
{
"source": "GoS",
"page": 33
},
{
"source": "ALbackground",
"page": 0
}
]
},
"smuggler": {
"name": "Smuggler",
"source": [
{
"source": "GoS",
"page": 34
},
{
"source": "ALbackground",
"page": 0
}
]
},
"celebrity adventurer's scion": {
"name": "Celebrity Adventurer",
"source": [
{
"source": "AcqInc",
"page": 48
}
]
},
"failed merchant": {
"name": "Failed Merchant",
"source": [
{
"source": "AcqInc",
"page": 49
}
]
},
"gambler": {
"name": "Gambler",
"source": [
{
"source": "AcqInc",
"page": 49
}
]
},
"plaintiff": {
"name": "Plaintiff",
"source": [
{
"source": "AcqInc",
"page": 50
}
]
},
"rival intern": {
"name": "Rival Intern",
"source": [
{
"source": "AcqInc",
"page": 51
}
]
},
"faceless": {
"name": "Faceless",
"source": [
{
"source": "DiA",
"page": 203
}
]
},
"house agent": {
"name": "Agent of House Cannith",
"source": [
{
"source": "E:RLW",
"page": 53
},
{
"source": "WGtE",
"page": 94
}
]
},
"grinner": {
"name": "Grinner",
"source": [
{
"source": "W",
"page": 200
}
]
},
"volstrucker agent": {
"name": "Volstrucker Agent",
"source": [
{
"source": "W",
"page": 202
}
]
},
"athlete": {
"name": "Athlete",
"source": [
{
"source": "MOT",
"page": 31
}
]
},
"investigator-vrgtr": {
"name": "Investigator (VRGtR)",
"source": [
{
"source": "VRGtR",
"page": 35
},
{
"source": "ALbackground",
"page": 0
}
]
},
"feylost": {
"name": "Feylost",
"source": [
{
"source": "WBtW",
"page": 9
},
{
"source": "ALbackground",
"page": 0
}
]
},
"witchlight hand": {
"name": "Witchlight Hand",
"source": [
{
"source": "WBtW",
"page": 11
},
{
"source": "ALbackground",
"page": 0
}
]
},
"lorehold student": {
"name": "Lorehold Student",
"source": [
{
"source": "SCC",
"page": 31
}
]
},
"prismari student": {
"name": "Prismari Student",
"source": [
{
"source": "SCC",
"page": 32
}
]
},
"quandrix student": {
"name": "Quandrix Student",
"source": [
{
"source": "SCC",
"page": 33
}
]
},
"silverquill student": {
"name": "Silverquill Student",
"source": [
{
"source": "SCC",
"page": 35
}
]
},
"witherbloom student": {
"name": "Witherbloom Student",
"source": [
{
"source": "SCC",
"page": 36
}
]
},
"astral drifter": {
"name": "Astral Drifter",
"source": [
{
"source": "S:AiS",
"page": 7
}
]
},
"wildspacer": {
"name": "Wildspacer",
"source": [
{
"source": "S:AiS",
"page": 8
}
]
},
"knight of solamnia": {
"name": "Knight of Solamnia",
"source": [
{
"source": "D:SotDQ",
"page": 0
},
{
"source": "UA:HoKR",
"page": 2
}
]
},
"mage of high sorcery": {
"name": "Mage of High Sorcery",
"source": [
{
"source": "D:SotDQ",
"page": 0
},
{
"source": "UA:HoKR",
"page": 3
},
{
"source": "UA:HoK",
"page": 4
}
]
},
"giant foundling": {
"name": "Giant Foundling",
"source": [
{
"source": "GotG",
"page": 12
}
]
},
"rune carver": {
"name": "Rune Carver",
"source": [
{
"source": "GotG",
"page": 14
}
]
},
"gate warden": {
"name": "Gate Warden",
"source": [
{
"source": "P:AitM",
"page": 7
},
{
"source": "UA:WotM",
"page": 3
}
]
},
"planar philosopher": {
"name": "Planar Philosopher",
"source": [
{
"source": "P:AitM",
"page": 8
}
]
},
"rewarded": {
"name": "Rewarded",
"source": [
{
"source": "BoMT",
"page": 57
}
]
},
"ruined": {
"name": "Ruined",
"source": [
{
"source": "BoMT",
"page": 58
}
]
}
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"sidekick-expert-tcoe": {
"name": "Expert (sidekick)",
"source": [
{
"source": "T",
"page": 143
}
]
},
"sidekick-spellcaster-tcoe": {
"name": "Spellcaster (sidekick)",
"source": [
{
"source": "T",
"page": 144
}
]
},
"sidekick-warrior-tcoe": {
"name": "Warrior (sidekick)",
"source": [
{
"source": "T",
"page": 146
}
]
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"undead_thrall": {
"name": "Undead Thralls",
"source": [
{
"source": "P",
"page": 119
}
],
"description": "add my wizard level to their hit point maximum and add my proficiency bonus to their weapon damage rolls."
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"rain catcher": {
"name": "Rain catcher [1 gp]",
"source": [
{
"source": "ToA",
"page": 32
}
],
"weight": 5
},
"insect repellent salve": {
"name": "Salve (vial) [5 sp]",
"type": "insect repellent",
"source": [
{
"source": "ToA",
"page": 32
}
]
},
"insect repellent incense": {
"name": "Incense (block) [1 gp]",
"type": "insect repellent",
"source": [
{
"source": "ToA",
"page": 32
}
]
},
"cold weather": {
"name": "Cold Weather [10 gp]",
"type": "clothes",
"source": [
{
"source": "RotF",
"page": 20
}
],
"weight": 5
},
"crampons (2)": {
"name": "Crampons (2) [2 gp]",
"source": [
{
"source": "RotF",
"page": 20
}
],
"weight": 0
},
"snowshoes": {
"name": "Snowshoes [2 gp]",
"source": [
{
"source": "RotF",
"page": 20
}
],
"weight": 4
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"LMoP": {
"name": "Lost Mines of Phandelver [items]"
},
"PotA": {
"name": "Princes of the Apocalypse [items]"
},
"AL:EE": {
"name": "Elemental Evil Backgrounds [Mulmaster]"
},
"AL:RoD": {
"name": "Rage of Demons Backgrounds [Hillsfar]"
},
"AL:CoS": {
"name": "Curse of Strahd Backgrounds"
},
"OGA": {
"name": "One Grung Above"
},
"WDH": {
"name": "Waterdeep: Dragon Heist [items]"
},
"LLoK": {
"name": "Lost Laboratory of Kwalish [items, spells]"
},
"WDotMM": {
"name": "Waterdeep: Dungeon of the Mad Mage [items]"
},
"GoS": {
"name": "Ghosts of Saltmarsh [backgrounds, beasts, items]"
},
"AcqInc": {
"name": "Acquisitions Incorporated"
},
"DiA": {
"name": "Baldur"
},
"AwM": {
"name": "Adventure with Muk"
},
"E:RLW": {
"name": "Eberron: Rising from the Last War"
},
"RotF": {
"name": "Icewind Dale: Rime of the Frostmaiden [creatures, items, spells]"
},
"S:AiS": {
"name": "Spelljammer: Adventures in Space"
},
"D:SotDQ": {
"name": "Dragonlance: Shadow of the Dragon Queen"
},
"KftGV": {
"name": "Keys from the Golden Vault [items]"
},
"GotG": {
"name": "Bigby Presents: Glory of the Giants"
},
"PaBTSO": {
"name": "Phandelver and Below: The Shattered Obelisk [items]"
},
"P:AitM": {
"name": "Planescape: Adventures in the Multiverse"
},
"CoA": {
"name": "Chains of Asmodeus"
},
"BoMT": {
"name": "The Book of Many Things"
},
"VEoR": {
"name": "Vecna: Eve of Ruin"
},
"QftIS": {
"name": "Quests from the Infinite Staircase"
},
"ALPGs9": {
"name": "AL Player"
}
}
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
{
"thorn whip": {
"name": "Thorn Whip",
"type": "Cantrip",
"source": [
{
"source": "P",
"page": 282
}
],
"description": "Melee spell attack, pull target up to 10 ft closer"
},
"pistol": {
"name": "Pistol",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, loading",
"weight": 3
},
"musket": {
"name": "Musket",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, loading, two-handed",
"weight": 10
},
"pistol automatic": {
"name": "Automatic pistol",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (15 shots)",
"weight": 3
},
"revolver": {
"name": "Revolver",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (6 shots)",
"weight": 3
},
"rifle hunting": {
"name": "Hunting rifle",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (5 shots), two-handed",
"weight": 8
},
"rifle automatic": {
"name": "Automatic rifle",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, burst fire, reload (30 shots), two-handed",
"weight": 8
},
"shotgun": {
"name": "Shotgun",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (2 shots), two-handed",
"weight": 7
},
"laser pistol": {
"name": "Laser pistol",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (50 shots), two-handed",
"weight": 2
},
"antimatter rifle": {
"name": "Antimatter rifle",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (2 shots), two-handed",
"weight": 10
},
"laser rifle": {
"name": "Laser rifle",
"type": "Martial",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "Ammunition, reload (30 shots), two-handed",
"weight": 7
},
"bomb, renaissance": {
"name": "Bomb, renaissance",
"type": "Explosive",
"source": [
{
"source": "D",
"page": 267
}
],
"description": "Dex save for all within 5-ft radius",
"weight": 1
},
"dynamite stick": {
"name": "Dynamite stick",
"type": "Explosive",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "All within 5-ft radius, Dex save halves; +1d6 damage \\u0026 +5 ft radius per extra stick used (max 10d6/20 ft)",
"weight": 1
},
"grenade, fragmentation": {
"name": "Fragmentation Grenade",
"type": "Explosive",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "All within 20-ft radius, Dex save halves",
"weight": 1
},
"create bonfire": {
"name": "Create Bonfire",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 152
},
{
"source": "E",
"page": 16
}
],
"description": "5-ft cube; Dex save at casting or when moved into, success - no damage; Conc, 1 min"
},
"frostbite": {
"name": "Frostbite",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 156
},
{
"source": "E",
"page": 18
}
],
"description": "Con save, success - no damage, fail - also disadv. on next weapon attack roll in next turn; 1 creature"
},
"magic stone": {
"name": "Magic Stone",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 160
},
{
"source": "E",
"page": 20
}
],
"description": "Produces 3 stones that each can be thrown (60 ft) or hurled with a sling (120 ft) as a spell attack"
},
"thunderclap": {
"name": "Thunderclap",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 168
},
{
"source": "E",
"page": 22
}
],
"description": "Con save, success - no damage; all creatures in area; audible in 100 ft"
},
"booming blade": {
"name": "Booming Blade",
"type": "Cantrip",
"source": [
{
"source": "T",
"page": 106
},
{
"source": "S",
"page": 142
}
],
"description": "First damage added to the attack; second to the target if it moves next round"
},
"green-flame blade": {
"name": "Green-Flame Blade",
"type": "Cantrip",
"source": [
{
"source": "T",
"page": 107
},
{
"source": "S",
"page": 143
}
],
"description": "First damage added to the attack; second to a target within 5 ft"
},
"lightning lure": {
"name": "Lightning Lure",
"type": "Cantrip",
"source": [
{
"source": "T",
"page": 107
},
{
"source": "S",
"page": 143
}
],
"description": "Str save; success - nothing; fail - pulled 10 ft closer to me, only take damage if end within 5 ft of me"
},
"sword burst": {
"name": "Sword Burst",
"type": "Cantrip",
"source": [
{
"source": "T",
"page": 115
},
{
"source": "S",
"page": 143
}
],
"description": "Dex save, success - no damage; all creatures in range"
},
"yklwa": {
"name": "Yklwa",
"type": "Simple",
"source": [
{
"source": "ToA",
"page": 32
}
],
"description": "Thrown",
"weight": 3
},
"shadow blade": {
"name": "Shadow Blade",
"type": "Simple",
"source": [
{
"source": "X",
"page": 164
}
],
"description": "Finesse, light, thrown; +1d8 at SL3/5/7; Adv. if target in dim light/darkness"
},
"infestation": {
"name": "Infestation",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 158
}
],
"description": "Con save, success - no damage, fail - target also moved 5 ft in random direction"
},
"primal savagery": {
"name": "Primal Savagery",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 169
}
]
},
"toll the dead": {
"name": "Toll the Dead",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 169
},
{
"source": "UA:SS",
"page": 4
}
],
"description": "Wis save, success - no damage; If target is at full HP, d8 instead of d12 damage"
},
"word of radiance": {
"name": "Word of Radiance",
"type": "Cantrip",
"source": [
{
"source": "X",
"page": 171
}
],
"description": "Con save, success - no damage; Only chosen creatures I can see are affected"
},
"oversized longbow": {
"name": "Oversized Longbow",
"type": "Martial",
"source": [
{
"source": "WDH",
"page": 201
}
],
"description": "Ammunition, heavy, two-handed; Damage uses Str; Requires Medium size and Str 18",
"weight": 2
},
"double-bladed scimitar": {
"name": "Double-bladed scimitar",
"type": "Martial",
"source": [
{
"source": "E:RLW",
"page": 22
},
{
"source": "WGtE",
"page": 74
}
],
"description": "Two-handed; With Attack action, one attack as bonus action for 1d4",
"weight": 6
},
"sapping sting": {
"name": "Sapping Sting",
"type": "Cantrip",
"source": [
{
"source": "W",
"page": 189
}
],
"description": "Con save, success - no damage, fail - also fall prone"
},
"mind sliver": {
"name": "Mind Sliver",
"type": "Cantrip",
"source": [
{
"source": "T",
"page": 108
},
{
"source": "UA:SnW",
"page": 4
},
{
"source": "UA:FRnW",
"page": 7
},
{
"source": "UA:POR",
"page": 7
}
],
"description": "1 creature Int save, success - no damage, fail - also -1d4 on first save before my next turn ends"
},
"hoopak, melee": {
"name": "Hoopak, melee",
"type": "Martial",
"source": [
{
"source": "D:SotDQ",
"page": 0
}
],
"description": "Finesse, two-handed; Can be used ranged (40/160 ft) with ammo, for 1d4 bludgeoning damage",
"weight": 2
},
"hoopak, ranged": {
"name": "Hoopak, ranged",
"type": "Martial",
"source": [
{
"source": "D:SotDQ",
"page": 0
}
],
"description": "Ammunition, finesse, two-handed; Can be used in melee without ammo, for 1d6 piercing damage",
"weight": 2
},
"grenade, concussion": {
"name": "Concussion Grenade",
"type": "Explosive",
"source": [
{
"source": "D",
"page": 268
}
],
"description": "All within 20-ft radius, Dex save halves",
"weight": 1
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"travelPace": [
{ "pace": "Fast", "perMinute": "400 feet", "perHour": "4 miles", "perDay": "30 miles", "effect": "-5 penalty to passive Wisdom (Perception) scores" },
{ "pace": "Normal", "perMinute": "300 feet", "perHour": "3 miles", "perDay": "24 miles", "effect": "—" },
{ "pace": "Slow", "perMinute": "200 feet", "perHour": "2 miles", "perDay": "18 miles", "effect": "Able to use stealth" }
],
"cover": [
{ "type": "Half Cover", "acBonus": "+2", "dexSaveBonus": "+2", "description": "A target has half cover if an obstacle blocks at least half of its body. The obstacle might be a low wall, a large piece of furniture, a narrow tree trunk, or a creature, whether that creature is an enemy or a friend." },
{ "type": "Three-Quarters Cover", "acBonus": "+5", "dexSaveBonus": "+5", "description": "A target has three-quarters cover if about three-quarters of it is covered by an obstacle. The obstacle might be a portcullis, an arrow slit, or a thick tree trunk." },
{ "type": "Total Cover", "acBonus": "—", "dexSaveBonus": "—", "description": "A target has total cover if it is completely concealed by an obstacle. A target with total cover can't be targeted directly by an attack or a spell, although some spells can reach such a target by including it in an area of effect." }
],
"lightAndVision": [
{ "type": "Bright Light", "description": "Lets most creatures see normally. Even gloomy days provide bright light, as do torches, lanterns, fires, and other sources of illumination within a specific radius." },
{ "type": "Dim Light", "description": "Also called shadows, creates a lightly obscured area. An area of dim light is usually a boundary between a source of bright light, such as a torch, and surrounding darkness. Creatures have disadvantage on Wisdom (Perception) checks that rely on sight." },
{ "type": "Darkness", "description": "Creates a heavily obscured area. Characters face darkness outdoors at night (even most moonlit nights), within the confines of an unlit dungeon or a subterranean vault, or in an area of magical darkness. A creature effectively suffers from the blinded condition when trying to see something in that area." },
{ "type": "Darkvision", "description": "Many creatures in fantasy gaming worlds, especially those that dwell underground, have darkvision. Within a specified range, a creature with darkvision can see in darkness as if the darkness were dim light, so areas of darkness are only lightly obscured as far as that creature is concerned. However, the creature can't discern color in darkness, only shades of gray." },
{ "type": "Truesight", "description": "A creature with truesight can, out to a specific range, see in normal and magical darkness, see invisible creatures and objects, automatically detect visual illusions and succeed on saving throws against them, and perceive the original form of a shapechanger or a creature that is transformed by magic. Furthermore, the creature can see into the Ethereal Plane." }
],
"abilityScores": [
{ "score": 1, "modifier": -5 },
{ "score": "2-3", "modifier": -4 },
{ "score": "4-5", "modifier": -3 },
{ "score": "6-7", "modifier": -2 },
{ "score": "8-9", "modifier": -1 },
{ "score": "10-11", "modifier": 0 },
{ "score": "12-13", "modifier": 1 },
{ "score": "14-15", "modifier": 2 },
{ "score": "16-17", "modifier": 3 },
{ "score": "18-19", "modifier": 4 },
{ "score": "20-21", "modifier": 5 },
{ "score": "22-23", "modifier": 6 },
{ "score": "24-25", "modifier": 7 },
{ "score": "26-27", "modifier": 8 },
{ "score": "28-29", "modifier": 9 },
{ "score": 30, "modifier": 10 }
],
"proficiencyBonus": [
{ "level": "1-4", "bonus": 2 },
{ "level": "5-8", "bonus": 3 },
{ "level": "9-12", "bonus": 4 },
{ "level": "13-16", "bonus": 5 },
{ "level": "17-20", "bonus": 6 }
]
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[
{ "name": "Magic Missile", "level": 1, "school": "Evocation", "castingTime": "1 action", "range": "120 feet", "components": "V, S", "duration": "Instantaneous", "description": "You create three glowing darts of magical force. Each dart hits a creature of your choice that you can see within range. A dart deals 1d4+1 force damage to its target. The darts all strike simultaneously, and you can direct them to hit one creature or several.", "higherLevels": "When you cast this spell using a spell slot of 2nd level or higher, the spell creates one more dart for each slot level above 1st." },
{ "name": "Cure Wounds", "level": 1, "school": "Evocation", "castingTime": "1 action", "range": "Touch", "components": "V, S", "duration": "Instantaneous", "description": "A creature you touch regains a number of hit points equal to 1d8 + your spellcasting ability modifier. This spell has no effect on undead or constructs.", "higherLevels": "When you cast this spell using a spell slot of 2nd level or higher, the healing increases by 1d8 for each slot level above 1st." },
{ "name": "Shield", "level": 1, "school": "Abjuration", "castingTime": "1 reaction (taken when hit by an attack or targeted by magic missile)", "range": "Self", "components": "V, S", "duration": "1 round", "description": "An invisible barrier of magical force appears and protects you. Until the start of your next turn, you have a +5 bonus to AC, including against the triggering attack, and you take no damage from magic missile." },
{ "name": "Mage Armor", "level": 1, "school": "Abjuration", "castingTime": "1 action", "range": "Touch", "components": "V, S, M (a piece of cured leather)", "duration": "8 hours", "description": "You touch a willing creature who isn't wearing armor, and a protective magical force surrounds it until the spell ends. The target's base AC becomes 13 + its Dexterity modifier. The spell ends if the target dons armor or if you dismiss the spell as an action." },
{ "name": "Burning Hands", "level": 1, "school": "Evocation", "castingTime": "1 action", "range": "Self (15-foot cone)", "components": "V, S", "duration": "Instantaneous", "description": "As you hold your hands with thumbs touching and fingers spread, a thin sheet of flames shoots forth from your outstretched fingertips. Each creature in a 15-foot cone must make a Dexterity saving throw. A creature takes 3d6 fire damage on a failed save, or half as much damage on a successful one. The fire ignites any flammable objects in the area that aren't being worn or carried.", "higherLevels": "When you cast this spell using a spell slot of 2nd level or higher, the damage increases by 1d6 for each slot level above 1st." },
{ "name": "Sleep", "level": 1, "school": "Enchantment", "castingTime": "1 action", "range": "90 feet", "components": "V, S, M (a pinch of fine sand, rose petals, or a cricket)", "duration": "1 minute", "description": "This spell sends creatures into a magical slumber. Roll 5d8; the total is how many hit points of creatures this spell can affect. Creatures within 20 feet of a point you choose within range are affected in ascending order of their current hit points. Starting with the creature that has the lowest current hit points, each creature affected by this spell falls unconscious until the spell ends, the sleeper takes damage, or someone uses an action to shake or slap the sleeper awake.", "higherLevels": "When you cast this spell using a spell slot of 2nd level or higher, roll an additional 2d8 for each slot level above 1st." },
{ "name": "Invisibility", "level": 2, "school": "Illusion", "castingTime": "1 action", "range": "Touch", "components": "V, S, M (an eyelash encased in gum arabic)", "duration": "Concentration, up to 1 hour", "description": "A creature you touch becomes invisible until the spell ends. Anything the target is wearing or carrying is invisible as long as it is on the target's person. The spell ends for a target that attacks or casts a spell.", "higherLevels": "When you cast this spell using a spell slot of 3rd level or higher, you can target one additional creature for each slot level above 2nd." },
{ "name": "Misty Step", "level": 2, "school": "Conjuration", "castingTime": "1 bonus action", "range": "Self", "components": "V", "duration": "Instantaneous", "description": "Briefly surrounded by silvery mist, you teleport up to 30 feet to an unoccupied space that you can see." },
{ "name": "Spiritual Weapon", "level": 2, "school": "Evocation", "castingTime": "1 bonus action", "range": "60 feet", "components": "V, S", "duration": "1 minute", "description": "You create a floating, spectral weapon within range that lasts for the duration or until you cast this spell again. When you cast the spell, you can make a melee spell attack against a creature within 5 feet of the weapon. On a hit, the target takes force damage equal to 1d8 + your spellcasting ability modifier. As a bonus action on your turn, you can move the weapon up to 20 feet and repeat the attack against a creature within 5 feet of it.", "higherLevels": "When you cast this spell using a spell slot of 3rd level or higher, the damage increases by 1d8 for every two slot levels above 2nd." },
{ "name": "Lesser Restoration", "level": 2, "school": "Abjuration", "castingTime": "1 action", "range": "Touch", "components": "V, S", "duration": "Instantaneous", "description": "You touch a creature and can end either one disease or one condition afflicting it. The condition can be blinded, deafened, paralyzed, or poisoned." },
{ "name": "Fireball", "level": 3, "school": "Evocation", "castingTime": "1 action", "range": "150 feet", "components": "V, S, M (a tiny ball of bat guano and sulfur)", "duration": "Instantaneous", "description": "A bright streak flashes from your pointing finger to a point you choose within range and then blossoms with a low roar into an explosion of flame. Each creature in a 20-foot-radius sphere centered on that point must make a Dexterity saving throw. A target takes 8d6 fire damage on a failed save, or half as much damage on a successful one. The fire spreads around corners. It ignites flammable objects in the area that aren't being worn or carried.", "higherLevels": "When you cast this spell using a spell slot of 4th level or higher, the damage increases by 1d6 for each slot level above 3rd." },
{ "name": "Counterspell", "level": 3, "school": "Abjuration", "castingTime": "1 reaction (taken when you see a creature within 60 feet casting a spell)", "range": "60 feet", "components": "S", "duration": "Instantaneous", "description": "You attempt to interrupt a creature in the process of casting a spell. If the creature is casting a spell of 3rd level or lower, its spell fails and has no effect. If it is casting a spell of 4th level or higher, make an ability check using your spellcasting ability. The DC equals 10 + the spell's level. On a success, the creature's spell fails and has no effect.", "higherLevels": "When you cast this spell using a spell slot of 4th level or higher, the interrupted spell has no effect if its level is less than or equal to the level of the spell slot you used." },
{ "name": "Fly", "level": 3, "school": "Transmutation", "castingTime": "1 action", "range": "Touch", "components": "V, S, M (a wing feather from any bird)", "duration": "Concentration, up to 10 minutes", "description": "You touch a willing creature. The target gains a flying speed of 60 feet for the duration. When the spell ends, the target falls if it is still aloft, unless it can stop the fall.", "higherLevels": "When you cast this spell using a spell slot of 4th level or higher, you can target one additional creature for each slot level above 3rd." },
{ "name": "Lightning Bolt", "level": 3, "school": "Evocation", "castingTime": "1 action", "range": "Self (100-foot line)", "components": "V, S, M (a bit of fur and a rod of amber, crystal, or glass)", "duration": "Instantaneous", "description": "A stroke of lightning forming a line 100 feet long and 5 feet wide blasts out from you in a direction you choose. Each creature in the line must make a Dexterity saving throw. A creature takes 8d6 lightning damage on a failed save, or half as much damage on a successful one.", "higherLevels": "When you cast this spell using a spell slot of 4th level or higher, the damage increases by 1d6 for each slot level above 3rd." },
{ "name": "Haste", "level": 3, "school": "Transmutation", "castingTime": "1 action", "range": "30 feet", "components": "V, S, M (a shaving of licorice root)", "duration": "Concentration, up to 1 minute", "description": "Choose a willing creature that you can see within range. Until the spell ends, the target's speed is doubled, it gains a +2 bonus to AC, it has advantage on Dexterity saving throws, and it gains an additional action on each of its turns. That action can be used only to take the Attack (one weapon attack only), Dash, Disengage, Hide, or Use an Object action. When the spell ends, the target can't move or take actions until after its next turn, as a wave of lethargy sweeps over it." },
{ "name": "Dispel Magic", "level": 3, "school": "Abjuration", "castingTime": "1 action", "range": "120 feet", "components": "V, S", "duration": "Instantaneous", "description": "Choose one creature, object, or magical effect within range. Any spell of 3rd level or lower on the target ends. For each spell of 4th level or higher on the target, make an ability check using your spellcasting ability. The DC equals 10 + the spell's level. On a successful check, the spell ends.", "higherLevels": "When you cast this spell using a spell slot of 4th level or higher, you automatically end the effects of a spell on the target if the spell's level is equal to or less than the level of the spell slot you used." },
{ "name": "Bless", "level": 1, "school": "Enchantment", "castingTime": "1 action", "range": "30 feet", "components": "V, S, M (a sprinkling of holy water)", "duration": "Concentration, up to 1 minute", "description": "You bless up to three creatures of your choice within range. Whenever a target makes an attack roll or a saving throw before the spell ends, the target can roll a d4 and add the number rolled to the attack roll or saving throw.", "higherLevels": "When you cast this spell using a spell slot of 2nd level or higher, you can target one additional creature for each slot level above 1st." },
{ "name": "Polymorph", "level": 4, "school": "Transmutation", "castingTime": "1 action", "range": "60 feet", "components": "V, S, M (a caterpillar cocoon)", "duration": "Concentration, up to 1 hour", "description": "This spell transforms a creature that you can see within range into a new form. An unwilling creature must make a Wisdom saving throw to avoid the effect. The transformation lasts for the duration, or until the target drops to 0 hit points or dies. The new form can be any beast whose challenge rating is equal to or less than the target's (or the target's level, if it doesn't have a challenge rating). The target's game statistics, including mental ability scores, are replaced by the statistics of the chosen beast. It retains its alignment and personality." },
{ "name": "Dimension Door", "level": 4, "school": "Conjuration", "castingTime": "1 action", "range": "500 feet", "components": "V", "duration": "Instantaneous", "description": "You teleport yourself from your current location to any other spot within range. You arrive at exactly the spot desired. It can be a place you can see, one you can visualize, or one you can describe by stating distance and direction. You can bring along objects as long as their weight doesn't exceed what you can carry. You can also bring one willing creature of your size or smaller who is carrying gear up to its carrying capacity. The creature must be within 5 feet of you when you cast this spell." },
{ "name": "Cone of Cold", "level": 5, "school": "Evocation", "castingTime": "1 action", "range": "Self (60-foot cone)", "components": "V, S, M (a small crystal or glass cone)", "duration": "Instantaneous", "description": "A blast of cold air erupts from your hands. Each creature in a 60-foot cone must make a Constitution saving throw. A creature takes 8d8 cold damage on a failed save, or half as much damage on a successful one. A creature killed by this spell becomes a frozen statue until it thaws.", "higherLevels": "When you cast this spell using a spell slot of 6th level or higher, the damage increases by 1d8 for each slot level above 5th." }
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+618
View File
@@ -0,0 +1,618 @@
[
{
"name": "Battleaxe",
"slug": "battleaxe",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "1d8",
"damage_type": "slashing",
"weight": "4 lb.",
"properties": [
"versatile (1d10)"
]
},
{
"name": "Blowgun",
"slug": "blowgun",
"category": "Martial Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "1",
"damage_type": "piercing",
"weight": "1 lb.",
"properties": [
"ammunition (range 25/100)",
"loading"
]
},
{
"name": "Club",
"slug": "club",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "1 sp",
"damage_dice": "1d4",
"damage_type": "bludgeoning",
"weight": "2 lb.",
"properties": [
"light"
]
},
{
"name": "Crossbow, hand",
"slug": "crossbow-hand",
"category": "Martial Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "75 gp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "3 lb.",
"properties": [
"ammunition (range 30/120)",
"light",
"loading"
]
},
{
"name": "Crossbow, heavy",
"slug": "crossbow-heavy",
"category": "Martial Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "50 gp",
"damage_dice": "1d10",
"damage_type": "piercing",
"weight": "18 lb.",
"properties": [
"ammunition (range 100/400)",
"heavy",
"loading",
"two-handed"
]
},
{
"name": "Crossbow, light",
"slug": "crossbow-light",
"category": "Simple Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "25 gp",
"damage_dice": "1d8",
"damage_type": "piercing",
"weight": "5 lb.",
"properties": [
"ammunition (range 80/320)",
"loading",
"two-handed"
]
},
{
"name": "Dagger",
"slug": "dagger",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "2 gp",
"damage_dice": "1d4",
"damage_type": "piercing",
"weight": "1 lb.",
"properties": [
"finesse",
"light",
"thrown (range 20/60)"
]
},
{
"name": "Dart",
"slug": "dart",
"category": "Simple Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 cp",
"damage_dice": "1d4",
"damage_type": "piercing",
"weight": "1/4 lb.",
"properties": [
"finesse",
"thrown (range 20/60)"
]
},
{
"name": "Flail",
"slug": "flail",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "1d8",
"damage_type": "bludgeoning",
"weight": "2 lb.",
"properties": []
},
{
"name": "Glaive",
"slug": "glaive",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "20 gp",
"damage_dice": "1d10",
"damage_type": "slashing",
"weight": "6 lb.",
"properties": [
"heavy",
"reach",
"two-handed"
]
},
{
"name": "Greataxe",
"slug": "greataxe",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "30 gp",
"damage_dice": "1d12",
"damage_type": "slashing",
"weight": "7 lb.",
"properties": [
"heavy",
"two-handed"
]
},
{
"name": "Greatclub",
"slug": "greatclub",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "2 sp",
"damage_dice": "1d8",
"damage_type": "bludgeoning",
"weight": "10 lb.",
"properties": [
"two-handed"
]
},
{
"name": "Greatsword",
"slug": "greatsword",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "50 gp",
"damage_dice": "2d6",
"damage_type": "slashing",
"weight": "6 lb.",
"properties": [
"heavy",
"two-handed"
]
},
{
"name": "Halberd",
"slug": "halberd",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "20 gp",
"damage_dice": "1d10",
"damage_type": "slashing",
"weight": "6 lb.",
"properties": [
"heavy",
"reach",
"two-handed"
]
},
{
"name": "Handaxe",
"slug": "handaxe",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 gp",
"damage_dice": "1d6",
"damage_type": "slashing",
"weight": "2 lb.",
"properties": [
"light",
"thrown (range 20/60)"
]
},
{
"name": "Javelin",
"slug": "javelin",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 sp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": [
"thrown (range 30/120)"
]
},
{
"name": "Lance",
"slug": "lance",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "1d12",
"damage_type": "piercing",
"weight": "6 lb.",
"properties": [
"reach",
"special"
]
},
{
"name": "Light hammer",
"slug": "light-hammer",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "2 gp",
"damage_dice": "1d4",
"damage_type": "bludgeoning",
"weight": "2 lb.",
"properties": [
"light",
"thrown (range 20/60)"
]
},
{
"name": "Longbow",
"slug": "longbow",
"category": "Martial Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "50 gp",
"damage_dice": "1d8",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": [
"ammunition (range 150/600)",
"heavy",
"two-handed"
]
},
{
"name": "Longsword",
"slug": "longsword",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "15 gp",
"damage_dice": "1d8",
"damage_type": "slashing",
"weight": "3 lb.",
"properties": [
"versatile (1d10)"
]
},
{
"name": "Mace",
"slug": "mace",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 gp",
"damage_dice": "1d6",
"damage_type": "bludgeoning",
"weight": "4 lb.",
"properties": []
},
{
"name": "Maul",
"slug": "maul",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "2d6",
"damage_type": "bludgeoning",
"weight": "10 lb.",
"properties": [
"heavy",
"two-handed"
]
},
{
"name": "Morningstar",
"slug": "morningstar",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "15 gp",
"damage_dice": "1d8",
"damage_type": "piercing",
"weight": "4 lb.",
"properties": null
},
{
"name": "Net",
"slug": "net",
"category": "Martial Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "1 gp",
"damage_dice": "0",
"damage_type": "",
"weight": "3 lb.",
"properties": [
"special",
"thrown (range 5/15)"
]
},
{
"name": "Pike",
"slug": "pike",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 gp",
"damage_dice": "1d10",
"damage_type": "piercing",
"weight": "18 lb.",
"properties": [
"heavy",
"reach",
"two-handed"
]
},
{
"name": "Quarterstaff",
"slug": "quarterstaff",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "2 sp",
"damage_dice": "1d6",
"damage_type": "bludgeoning",
"weight": "4 lb.",
"properties": [
"versatile (1d8)"
]
},
{
"name": "Rapier",
"slug": "rapier",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "25 gp",
"damage_dice": "1d8",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": [
"finesse"
]
},
{
"name": "Scimitar",
"slug": "scimitar",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "25 gp",
"damage_dice": "1d6",
"damage_type": "slashing",
"weight": "3 lb.",
"properties": [
"finesse",
"light"
]
},
{
"name": "Shortbow",
"slug": "shortbow",
"category": "Simple Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "25 gp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": [
"ammunition (range 80/320)",
"two-handed"
]
},
{
"name": "Shortsword",
"slug": "shortsword",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "10 gp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": [
"finesse",
"light"
]
},
{
"name": "Sickle",
"slug": "sickle",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "1 gp",
"damage_dice": "1d4",
"damage_type": "slashing",
"weight": "2 lb.",
"properties": [
"light"
]
},
{
"name": "Sling",
"slug": "sling",
"category": "Simple Ranged Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "1 sp",
"damage_dice": "1d4",
"damage_type": "bludgeoning",
"weight": "0 lb.",
"properties": [
"ammunition (range 30/120)"
]
},
{
"name": "Spear",
"slug": "spear",
"category": "Simple Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "1 gp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "3 lb.",
"properties": [
"thrown (range 20/60)",
"versatile (1d8)"
]
},
{
"name": "Trident",
"slug": "trident",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 gp",
"damage_dice": "1d6",
"damage_type": "piercing",
"weight": "4 lb.",
"properties": [
"thrown (range 20/60)",
"versatile (1d8)"
]
},
{
"name": "War pick",
"slug": "war-pick",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "5 gp",
"damage_dice": "1d8",
"damage_type": "piercing",
"weight": "2 lb.",
"properties": []
},
{
"name": "Warhammer",
"slug": "warhammer",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "15 gp",
"damage_dice": "1d8",
"damage_type": "bludgeoning",
"weight": "2 lb.",
"properties": [
"versatile (1d10)"
]
},
{
"name": "Whip",
"slug": "whip",
"category": "Martial Melee Weapons",
"document__slug": "wotc-srd",
"document__title": "5e Core Rules",
"document__license_url": "http://open5e.com/legal",
"document__url": "http://dnd.wizards.com/articles/features/systems-reference-document-srd",
"cost": "2 gp",
"damage_dice": "1d4",
"damage_type": "slashing",
"weight": "3 lb.",
"properties": [
"finesse",
"reach"
]
}
]
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { campaignsRepo } from '@/lib/db/repositories';
import { SYSTEM_OPTIONS } from '@/lib/rules';
import { useUiStore } from '@/stores/uiStore';
import { useCampaigns } from './hooks';
import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
export function CampaignsPage() {
const campaigns = useCampaigns();
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<Campaign | null>(null);
return (
<Page>
<PageHeader
title="Campaigns"
subtitle="Each campaign holds its own characters, encounters, and dice history."
actions={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New campaign
</Button>
}
/>
{campaigns.length === 0 ? (
<EmptyState
title="No campaigns yet"
hint="Create your first campaign to start tracking characters and combat."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New campaign
</Button>
}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{campaigns.map((c) => (
<CampaignCard key={c.id} campaign={c} onEdit={() => setEditing(c)} />
))}
</div>
)}
{creating && <CampaignFormModal onClose={() => setCreating(false)} />}
{editing && <CampaignFormModal campaign={editing} onClose={() => setEditing(null)} />}
</Page>
);
}
function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () => void }) {
const navigate = useNavigate();
const setActive = useUiStore((s) => s.setActiveCampaign);
const activeId = useUiStore((s) => s.activeCampaignId);
const [confirming, setConfirming] = useState(false);
const open = () => {
setActive(campaign.id);
void navigate({ to: '/characters' });
};
return (
<div className="group relative flex flex-col rounded-lg border border-line bg-panel p-4">
{activeId === campaign.id && (
<span className="absolute right-3 top-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
Active
</span>
)}
<div className="text-xs font-medium uppercase tracking-wide text-muted">
{campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e'}
</div>
<h3 className="mt-1 font-display text-lg font-semibold text-ink">{campaign.name}</h3>
{campaign.description && (
<p className="mt-1 line-clamp-3 text-sm text-muted">{campaign.description}</p>
)}
<div className="mt-4 flex items-center gap-2">
<Button size="sm" variant="primary" onClick={open}>
Open
</Button>
<Button size="sm" variant="ghost" onClick={onEdit}>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="ml-auto text-danger hover:bg-danger/10"
onClick={() => setConfirming(true)}
>
Delete
</Button>
</div>
<Modal
open={confirming}
onClose={() => setConfirming(false)}
title="Delete campaign?"
footer={
<>
<Button variant="ghost" onClick={() => setConfirming(false)}>
Cancel
</Button>
<Button
variant="danger"
onClick={async () => {
await campaignsRepo.remove(campaign.id);
if (activeId === campaign.id) setActive(null);
setConfirming(false);
}}
>
Delete everything
</Button>
</>
}
>
<p className="text-sm text-muted">
This permanently deletes <strong className="text-ink">{campaign.name}</strong> and all of its
characters, encounters, and dice history. This cannot be undone.
</p>
</Modal>
</div>
);
}
function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose: () => void }) {
const setActive = useUiStore((s) => s.setActiveCampaign);
const [name, setName] = useState(campaign?.name ?? '');
const [system, setSystem] = useState(campaign?.system ?? '5e');
const [description, setDescription] = useState(campaign?.description ?? '');
const [error, setError] = useState<string | null>(null);
const save = async () => {
if (name.trim() === '') {
setError('Name is required');
return;
}
if (campaign) {
await campaignsRepo.update(campaign.id, { name: name.trim(), system, description });
} else {
const created = await campaignsRepo.create({ name: name.trim(), system, description });
setActive(created.id);
}
onClose();
};
return (
<Modal
open
onClose={onClose}
title={campaign ? 'Edit campaign' : 'New campaign'}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={save}>
{campaign ? 'Save' : 'Create'}
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Name">
<Input
data-autofocus
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="Curse of Strahd"
/>
</Field>
<Field label="System">
<Select
value={system}
disabled={!!campaign}
onChange={(e) => setSystem(e.target.value as typeof system)}
>
{SYSTEM_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</Select>
</Field>
<Field label="Description">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A gothic horror campaign in the mists of Barovia…"
/>
</Field>
{error && <p className="text-sm text-danger">{error}</p>}
</div>
</Modal>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { campaignsRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import type { Campaign } from '@/lib/schemas';
export function useCampaigns(): Campaign[] {
return useLiveQuery(() => campaignsRepo.list(), [], []);
}
export function useActiveCampaign(): Campaign | undefined {
const id = useUiStore((s) => s.activeCampaignId);
return useLiveQuery(
() => (id ? campaignsRepo.get(id) : Promise.resolve(undefined)),
[id],
undefined,
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo } from '@/lib/db/repositories';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
const RANK_LABEL: Record<ProficiencyRank, string> = {
untrained: 'Untrained',
trained: 'Trained',
expert: 'Expert',
master: 'Master',
legendary: 'Legendary',
};
export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const save = useDebouncedCallback((next: Character) => {
void charactersRepo.update(next.id, {
name: next.name,
ancestry: next.ancestry,
className: next.className,
level: next.level,
kind: next.kind,
abilities: next.abilities,
hp: next.hp,
speed: next.speed,
armorBonus: next.armorBonus,
skillRanks: next.skillRanks,
saveRanks: next.saveRanks,
perceptionRank: next.perceptionRank,
...(next.spellcastingAbility ? { spellcastingAbility: next.spellcastingAbility } : {}),
...(next.spellcastingRank ? { spellcastingRank: next.spellcastingRank } : {}),
notes: next.notes,
});
}, 350);
const update = (patch: Partial<Character>) => {
setC((prev) => {
const next = { ...prev, ...patch };
save(next);
return next;
});
};
const sys = getSystem(c.system);
const rulesInput: CharacterRulesInput = {
level: c.level,
abilities: c.abilities,
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput);
const saves = sys.saveModifiers(rulesInput);
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
return (
<Page>
<div className="mb-4">
<Link to="/characters" className="text-sm text-muted hover:text-ink">
Back to characters
</Link>
</div>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<Input
className="max-w-xs font-display text-xl font-bold"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
</span>
<div className="ml-auto text-sm text-muted">{profLabel}</div>
</div>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled>
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled>
</div>
{/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</StatCard>
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
<HpCard c={c} update={update} />
</div>
{/* Abilities */}
<section className="mb-6">
<SectionTitle>Ability Scores</SectionTitle>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
return (
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
<NumberField
value={c.abilities[a]}
min={1}
max={30}
aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
/>
</div>
);
})}
</div>
</section>
{/* Saves */}
<section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{c.system === 'pf2e'
? PF2E_SAVES.map((s) => (
<RankRow
key={s.key}
label={s.label}
modifier={saves[s.ability]}
rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })}
/>
))
: ABILITIES.map((a) => (
<RankRow
key={a}
label={`${ABILITY_ABBR[a]} save`}
modifier={saves[a]}
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
/>
))}
</div>
</section>
{/* Skills */}
<section className="mb-6">
<SectionTitle>Skills</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((s) => (
<RankRow
key={s.key}
label={`${s.label} (${ABILITY_ABBR[s.ability]})`}
modifier={s.modifier}
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
/>
))}
</div>
</section>
{/* Notes */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
<textarea
value={c.notes}
onChange={(e) => update({ notes: e.target.value })}
placeholder="Backstory, bonds, inventory, reminders…"
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
</section>
</Page>
);
}
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [delta, setDelta] = useState(0);
const apply = (mode: 'damage' | 'heal') => {
if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} else {
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } });
}
setDelta(0);
};
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
<div className="my-1 font-display text-2xl font-bold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
<label>Temp<NumberField value={c.hp.temp} min={0} onChange={(temp) => update({ hp: { ...c.hp, temp } })} aria-label="Temp HP" /></label>
</div>
<div className="mt-2 flex items-center gap-1">
<NumberField className="w-16" value={delta} min={0} onChange={setDelta} aria-label="HP change amount" />
<Button size="sm" variant="danger" className="flex-1" onClick={() => apply('damage')}>
Damage
</Button>
<Button size="sm" variant="secondary" className="flex-1" onClick={() => apply('heal')}>
Heal
</Button>
</div>
</div>
);
}
function RankRow({
label,
modifier,
rank,
ranks,
onRank,
}: {
label: string;
modifier: number;
rank: ProficiencyRank;
ranks: ProficiencyRank[];
onRank: (r: ProficiencyRank) => void;
}) {
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
<span className="w-8 font-display text-lg font-semibold text-accent">{formatModifier(modifier)}</span>
<span className="flex-1 truncate text-sm text-ink">{label}</span>
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => (
<option key={r} value={r}>
{RANK_LABEL[r]}
</option>
))}
</Select>
</div>
);
}
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
{children}
</label>
);
}
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
{hint && <div className="text-xs text-muted">{hint}</div>}
{children}
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
}
@@ -0,0 +1,20 @@
import { useParams } from '@tanstack/react-router';
import { useCharacter } from './hooks';
import { CharacterSheet } from './CharacterSheet';
import { Page, EmptyState } from '@/components/ui/Page';
export function CharacterSheetPage() {
const { characterId } = useParams({ from: '/characters/$characterId' });
const character = useCharacter(characterId);
if (character === undefined) {
// useLiveQuery returns undefined while loading AND when not found.
return (
<Page>
<EmptyState title="Loading character…" />
</Page>
);
}
// Keyed so local sheet state resets cleanly when switching characters.
return <CharacterSheet key={character.id} character={character} />;
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select } from '@/components/ui/Input';
export function CharactersPage() {
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
}
function CharactersList({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const [creating, setCreating] = useState(false);
const pcs = characters.filter((c) => c.kind === 'pc');
const npcs = characters.filter((c) => c.kind === 'npc');
return (
<Page>
<PageHeader
title="Characters"
subtitle={campaign.name}
actions={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
</Button>
}
/>
{characters.length === 0 ? (
<EmptyState
title="No characters yet"
hint="Add player characters and NPCs to this campaign."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
</Button>
}
/>
) : (
<div className="space-y-6">
<CharacterGroup title="Player Characters" items={pcs} />
<CharacterGroup title="NPCs" items={npcs} />
</div>
)}
{creating && <CharacterFormModal campaign={campaign} onClose={() => setCreating(false)} />}
</Page>
);
}
function CharacterGroup({ title, items }: { title: string; items: Character[] }) {
if (items.length === 0) return null;
return (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="rounded-lg border border-line bg-panel p-4 transition-colors hover:border-accent/50"
>
<div className="flex items-baseline justify-between">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
<span className="text-sm text-muted">Lv {c.level}</span>
</div>
<p className="mt-0.5 text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
<div className="mt-3 flex gap-4 text-sm">
<span className="text-muted">
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
</span>
<span className="text-muted">
AC <span className="font-medium text-ink">{getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}</span>
</span>
</div>
</Link>
))}
</div>
</section>
);
}
function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');
const [ancestry, setAncestry] = useState('');
const [className, setClassName] = useState('');
const [level, setLevel] = useState(1);
const [error, setError] = useState<string | null>(null);
const save = async () => {
if (name.trim() === '') {
setError('Name is required');
return;
}
await charactersRepo.create(campaign.id, {
system: campaign.system,
name: name.trim(),
kind,
ancestry: ancestry.trim(),
className: className.trim(),
level,
});
onClose();
};
return (
<Modal
open
onClose={onClose}
title="New character"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={save}>
Create
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Name">
<Input data-autofocus value={name} onChange={(e) => { setName(e.target.value); setError(null); }} placeholder="Aria Stormwind" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Type">
<Select value={kind} onChange={(e) => setKind(e.target.value as Character['kind'])}>
<option value="pc">Player Character</option>
<option value="npc">NPC</option>
</Select>
</Field>
<Field label="Level">
<Input
type="number"
min={1}
max={20}
value={level}
onChange={(e) => setLevel(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={ancestry} onChange={(e) => setAncestry(e.target.value)} placeholder={campaign.system === 'pf2e' ? 'Dwarf' : 'Half-Elf'} />
</Field>
<Field label="Class">
<Input value={className} onChange={(e) => setClassName(e.target.value)} placeholder="Fighter" />
</Field>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
</div>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { charactersRepo } from '@/lib/db/repositories';
import type { Character } from '@/lib/schemas';
export function useCharacters(campaignId: string): Character[] {
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useCharacter(id: string): Character | undefined {
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from 'react';
import type { Campaign } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker';
export function CombatPage() {
return <RequireCampaign>{(campaign) => <Combat campaign={campaign} />}</RequireCampaign>;
}
const STATUS_LABEL = { planning: 'Not started', active: 'In progress', ended: 'Ended' } as const;
function Combat({ campaign }: { campaign: Campaign }) {
const encounters = useEncounters(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const setActiveEncounter = useUiStore((s) => s.setActiveEncounter);
const selected = useEncounter(activeEncounterId);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
// Only treat the selection as valid if it belongs to this campaign.
const validSelection = selected && selected.campaignId === campaign.id ? selected : undefined;
const create = async () => {
const name = newName.trim() || 'Encounter';
const enc = await encountersRepo.create(campaign.id, name);
setActiveEncounter(enc.id);
setNewName('');
setCreating(false);
};
return (
<Page>
<PageHeader
title="Combat"
subtitle={campaign.name}
actions={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New encounter
</Button>
}
/>
{encounters.length === 0 ? (
<EmptyState
title="No encounters yet"
hint="Create an encounter, add combatants, roll initiative, and run the fight."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New encounter
</Button>
}
/>
) : (
<div className="grid gap-6 lg:grid-cols-[240px_1fr]">
<aside className="space-y-1">
{encounters.map((e) => (
<div
key={e.id}
className={
'group flex items-center gap-1 rounded-md border px-2 ' +
(validSelection?.id === e.id ? 'border-accent bg-elevated' : 'border-line')
}
>
<button
className="flex-1 py-2 text-left text-sm"
onClick={() => setActiveEncounter(e.id)}
>
<span className="block truncate text-ink">{e.name}</span>
<span className="text-xs text-muted">
{STATUS_LABEL[e.status]} · {e.combatants.length} combatants
</span>
</button>
<button
className="px-1 text-muted opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
aria-label={`Delete ${e.name}`}
onClick={async () => {
await encountersRepo.remove(e.id);
if (activeEncounterId === e.id) setActiveEncounter(null);
}}
>
</button>
</div>
))}
</aside>
<div>
{validSelection ? (
<EncounterTracker encounter={validSelection} campaign={campaign} />
) : (
<EmptyState title="Select an encounter" hint="Pick one from the list, or create a new one." />
)}
</div>
</div>
)}
<Modal
open={creating}
onClose={() => setCreating(false)}
title="New encounter"
footer={
<>
<Button variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button variant="primary" onClick={create}>
Create
</Button>
</>
}
>
<Input
data-autofocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
placeholder="Ambush on the road"
/>
</Modal>
</Page>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useState } from 'react';
import type { Campaign, Character, Combatant, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem } from '@/lib/rules';
import { useCharacters } from '@/features/characters/hooks';
import {
addCombatant,
applyDamage,
applyHealing,
currentCombatant,
endEncounter,
moveCombatant,
nextTurn,
previousTurn,
removeCombatant,
startEncounter,
updateCombatant,
} from '@/lib/combat/engine';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const mutate = (fn: (e: Encounter) => Encounter) => {
void encountersRepo.save(fn(encounter));
};
const current = currentCombatant(encounter);
const isActive = encounter.status === 'active';
return (
<div>
{/* Control bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-line bg-panel p-3">
<div>
<div className="font-display text-lg font-semibold text-ink">{encounter.name}</div>
<div className="text-xs text-muted">
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
{current && isActive ? ` · ${current.name}'s turn` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
{!isActive && encounter.status !== 'ended' && (
<Button
variant="primary"
disabled={encounter.combatants.length === 0}
onClick={() => mutate(startEncounter)}
>
Start combat
</Button>
)}
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
Prev
</Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}>
Next turn
</Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
End
</Button>
</>
)}
{encounter.status === 'ended' && (
<Button variant="secondary" onClick={() => mutate((e) => ({ ...e, status: 'planning', round: 0, turnIndex: 0 }))}>
Reopen
</Button>
)}
</div>
</div>
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
{/* Combatant list */}
{encounter.combatants.length === 0 ? (
<p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p>
) : (
<ul className="mt-4 space-y-2">
{encounter.combatants.map((c, idx) => (
<CombatantRow
key={c.id}
combatant={c}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
onHeal={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }))}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => removeCombatant(e, c.id))}
/>
))}
</ul>
)}
</div>
);
}
function rollInitiativeFor(character: Character | undefined): number {
if (!character) return rollDice('1d20', createRng()).total;
const mod = getSystem(character.system).initiativeModifier({
level: character.level,
abilities: character.abilities,
perceptionRank: character.perceptionRank,
});
return rollDice('1d20', createRng()).total + mod;
}
function AddCombatantBar({
characters,
onAdd,
}: {
encounter: Encounter;
characters: Character[];
onAdd: (c: Combatant) => void;
}) {
const [name, setName] = useState('');
const [init, setInit] = useState(10);
const [ac, setAc] = useState(12);
const [hp, setHp] = useState(10);
const addCustom = () => {
if (name.trim() === '') return;
onAdd({
id: newId(),
name: name.trim(),
kind: 'monster',
initiative: init,
ac,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
notes: '',
});
setName('');
};
const addCharacter = (charId: string) => {
const ch = characters.find((c) => c.id === charId);
if (!ch) return;
const sys = getSystem(ch.system);
onAdd({
id: newId(),
name: ch.name,
kind: ch.kind,
characterId: ch.id,
initiative: rollInitiativeFor(ch),
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }),
hp: { ...ch.hp },
conditions: [],
notes: '',
});
};
return (
<div className="space-y-2 rounded-lg border border-line bg-panel/60 p-3">
<div className="flex flex-wrap items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
Name
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Goblin Boss" onKeyDown={(e) => e.key === 'Enter' && addCustom()} />
</label>
<label className="text-xs text-muted">
Init
<NumberField className="w-16" value={init} onChange={setInit} aria-label="Initiative" />
</label>
<label className="text-xs text-muted">
AC
<NumberField className="w-16" value={ac} min={0} onChange={setAc} aria-label="Armor class" />
</label>
<label className="text-xs text-muted">
HP
<NumberField className="w-20" value={hp} min={0} onChange={setHp} aria-label="Hit points" />
</label>
<Button variant="primary" onClick={addCustom}>
Add
</Button>
</div>
{characters.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Add character (rolls initiative):</span>
<Select
className="w-auto"
value=""
onChange={(e) => {
if (e.target.value) addCharacter(e.target.value);
e.target.value = '';
}}
>
<option value="">Choose</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name} (Lv {c.level})
</option>
))}
</Select>
</div>
)}
</div>
);
}
function CombatantRow({
combatant: c,
isCurrent,
onChange,
onDamage,
onHeal,
onMove,
onRemove,
}: {
combatant: Combatant;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
onHeal: (amt: number) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [conditionText, setConditionText] = useState('');
const dead = c.hp.current <= 0;
return (
<li
className={
'rounded-lg border bg-panel p-3 ' +
(isCurrent ? 'border-accent ring-1 ring-accent/40' : 'border-line') +
(dead ? ' opacity-60' : '')
}
>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-center">
<NumberField className="w-14" value={c.initiative} onChange={(initiative) => onChange({ initiative })} aria-label={`${c.name} initiative`} />
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
</div>
<div className="min-w-32 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-ink">{c.name}</span>
<span className="rounded bg-elevated px-1.5 text-[10px] uppercase text-muted">{c.kind}</span>
{dead && <span className="text-xs font-semibold text-danger">DOWN</span>}
</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
key={`${cond.name}-${i}`}
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through"
title="Click to remove"
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
</button>
))}
</div>
)}
</div>
{/* HP */}
<div className="text-center">
<div className="text-sm">
<span className={dead ? 'text-danger' : 'text-ink'}>{c.hp.current}</span>
<span className="text-muted">/{c.hp.max}</span>
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</div>
<div className="mt-1 flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP change`} />
<Button size="sm" variant="danger" onClick={() => { onDamage(delta); setDelta(0); }}>
</Button>
<Button size="sm" variant="secondary" onClick={() => { onHeal(delta); setDelta(0); }}>
+
</Button>
</div>
</div>
<div className="text-center text-sm">
<div className="text-muted">AC</div>
<NumberField className="w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
</div>
<div className="flex flex-col gap-1">
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up"></Button>
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down"></Button>
</div>
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}></Button>
</div>
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 max-w-48 text-xs"
value={conditionText}
placeholder="Add condition (e.g. Prone, Frightened 2)"
onChange={(e) => setConditionText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && conditionText.trim()) {
const m = /^(.*?)(?:\s+(\d+))?$/.exec(conditionText.trim());
const nameOnly = (m?.[1] ?? conditionText).trim();
const value = m?.[2] ? Number(m[2]) : undefined;
onChange({ conditions: [...c.conditions, { name: nameOnly, ...(value ? { value } : {}) }] });
setConditionText('');
}
}}
/>
</div>
</li>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { encountersRepo } from '@/lib/db/repositories';
import type { Encounter } from '@/lib/schemas';
export function useEncounters(campaignId: string): Encounter[] {
return useLiveQuery(() => encountersRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useEncounter(id: string | null): Encounter | undefined {
return useLiveQuery(
() => (id ? encountersRepo.get(id) : Promise.resolve(undefined)),
[id],
undefined,
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from 'react';
import Fuse from 'fuse.js';
import type { CompendiumKind, Monster, Spell, MagicItem } from '@/lib/compendium/types';
import { loadMonsters, loadSpells, loadItems, crLabel } from '@/lib/compendium';
import { abilityModifier } from '@/lib/rules';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { encountersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
import { MonsterDetail } from './MonsterDetail';
type AnyEntry = Monster | Spell | MagicItem;
const TABS: { kind: CompendiumKind; label: string }[] = [
{ kind: 'monsters', label: 'Bestiary' },
{ kind: 'spells', label: 'Spells' },
{ kind: 'items', label: 'Magic Items' },
];
export function CompendiumPage() {
const [kind, setKind] = useState<CompendiumKind>('monsters');
const [data, setData] = useState<AnyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<AnyEntry | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setSelected(null);
const loader = kind === 'monsters' ? loadMonsters : kind === 'spells' ? loadSpells : loadItems;
void loader().then((d) => {
if (cancelled) return;
setData(d);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [kind]);
const fuse = useMemo(
() => new Fuse(data, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
[data],
);
const results = useMemo(() => {
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : data;
return list.slice(0, 300);
}, [query, fuse, data]);
return (
<Page>
<PageHeader title="Compendium" subtitle="SRD reference for D&D 5e — searchable bestiary, spells, and items." />
<div className="mb-4 flex gap-1">
{TABS.map((t) => (
<button
key={t.kind}
onClick={() => { setKind(t.kind); setQuery(''); }}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
kind === t.kind ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink',
)}
>
{t.label}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
<div>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${kind}`}
aria-label="Search compendium"
/>
<p className="mt-1 text-xs text-muted">
{loading ? 'Loading…' : `${results.length}${results.length === 300 ? '+' : ''} results`}
</p>
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
{results.map((entry) => (
<li key={(entry as { slug: string }).slug}>
<button
onClick={() => setSelected(entry)}
className={cn(
'flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm',
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
)}
>
<span className="truncate text-ink">{entry.name}</span>
<EntryMeta kind={kind} entry={entry} />
</button>
</li>
))}
</ul>
</div>
<div className="rounded-lg border border-line bg-panel p-5">
{selected ? (
<DetailView kind={kind} entry={selected} />
) : (
<p className="text-sm text-muted">Select an entry to view details.</p>
)}
</div>
</div>
</Page>
);
}
function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
if (kind === 'monsters') return <span className="text-xs text-muted">CR {crLabel(entry as Monster)}</span>;
if (kind === 'spells') {
const lvl = (entry as Spell).level_int;
return <span className="text-xs text-muted">{lvl === 0 ? 'Cantrip' : `Lv ${lvl ?? '?'}`}</span>;
}
return <span className="text-xs text-muted">{(entry as MagicItem).rarity ?? ''}</span>;
}
function DetailView({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
if (kind === 'monsters') return <MonsterPane monster={entry as Monster} />;
if (kind === 'spells') return <SpellPane spell={entry as Spell} />;
return <ItemPane item={entry as MagicItem} />;
}
function MonsterPane({ monster }: { monster: Monster }) {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const [msg, setMsg] = useState<string | null>(null);
const addToCombat = async () => {
if (!activeEncounterId) return;
const enc = await encountersRepo.get(activeEncounterId);
if (!enc) {
setMsg('Open an encounter in the Combat tab first.');
return;
}
const initiative = rollDice('1d20', createRng()).total + abilityModifier(monster.dexterity ?? 10);
const hp = monster.hit_points ?? 1;
await encountersRepo.save(
addCombatant(enc, {
id: newId(),
name: monster.name,
kind: 'monster',
monsterRef: monster.slug,
initiative,
ac: monster.armor_class ?? 10,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
notes: '',
}),
);
setMsg(`Added to “${enc.name}” (initiative ${initiative}).`);
};
return (
<div>
<div className="mb-3 flex items-center gap-2">
<Button
variant="primary"
size="sm"
disabled={!activeEncounterId}
onClick={addToCombat}
title={activeEncounterId ? 'Add to the open encounter' : 'Open an encounter in Combat first'}
>
+ Add to combat
</Button>
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
</div>
<MonsterDetail monster={monster} />
</div>
);
}
function SpellPane({ spell: s }: { spell: Spell }) {
return (
<div className="space-y-3">
<header>
<h2 className="font-display text-2xl font-bold text-accent">{s.name}</h2>
<p className="text-sm italic text-muted">
{s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
</p>
</header>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span>
<span><strong className="text-ink">Range</strong> {s.range}</span>
<span><strong className="text-ink">Duration</strong> {s.duration}</span>
<span><strong className="text-ink">Components</strong> {s.components}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-ink">{s.desc}</p>
{s.higher_level && (
<p className="whitespace-pre-wrap text-sm text-muted">
<strong className="text-ink">At Higher Levels.</strong> {s.higher_level}
</p>
)}
</div>
);
}
function ItemPane({ item }: { item: MagicItem }) {
return (
<div className="space-y-3">
<header>
<h2 className="font-display text-2xl font-bold text-accent">{item.name}</h2>
<p className="text-sm italic text-muted">
{[item.type, item.rarity].filter(Boolean).join(', ')}
{item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : ''}
</p>
</header>
<p className="whitespace-pre-wrap text-sm text-ink">{item.desc}</p>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { Monster, MonsterAction } from '@/lib/compendium/types';
import { crLabel } from '@/lib/compendium';
import { abilityModifier } from '@/lib/rules';
import { formatModifier } from '@/lib/format';
function actionList(value: MonsterAction[] | string | undefined): MonsterAction[] {
return Array.isArray(value) ? value : [];
}
export function MonsterDetail({ monster: m }: { monster: Monster }) {
const abilities: [string, number | undefined][] = [
['STR', m.strength],
['DEX', m.dexterity],
['CON', m.constitution],
['INT', m.intelligence],
['WIS', m.wisdom],
['CHA', m.charisma],
];
return (
<div className="space-y-4">
<header>
<h2 className="font-display text-2xl font-bold text-accent">{m.name}</h2>
<p className="text-sm italic text-muted">
{[m.size, m.type, m.alignment].filter(Boolean).join(', ')}
</p>
</header>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<span><strong className="text-ink">AC</strong> {m.armor_class ?? '—'} {m.armor_desc ? `(${m.armor_desc})` : ''}</span>
<span><strong className="text-ink">HP</strong> {m.hit_points ?? '—'} {m.hit_dice ? `(${m.hit_dice})` : ''}</span>
<span><strong className="text-ink">CR</strong> {crLabel(m)}</span>
{m.speed?.walk !== undefined && <span><strong className="text-ink">Speed</strong> {m.speed.walk} ft.</span>}
</div>
<div className="grid grid-cols-6 gap-2 rounded-lg border border-line bg-surface p-3 text-center text-sm">
{abilities.map(([label, score]) => (
<div key={label}>
<div className="text-xs font-semibold text-muted">{label}</div>
<div className="text-ink">{score ?? '—'}</div>
<div className="text-xs text-accent">
{score !== undefined ? formatModifier(abilityModifier(score)) : ''}
</div>
</div>
))}
</div>
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && (
<div className="space-y-1 text-sm text-muted">
{m.damage_resistances && <p><strong className="text-ink">Resistances</strong> {m.damage_resistances}</p>}
{m.damage_immunities && <p><strong className="text-ink">Immunities</strong> {m.damage_immunities}</p>}
{m.condition_immunities && <p><strong className="text-ink">Condition Immunities</strong> {m.condition_immunities}</p>}
{m.senses && <p><strong className="text-ink">Senses</strong> {m.senses}</p>}
{m.languages && <p><strong className="text-ink">Languages</strong> {m.languages}</p>}
</div>
)}
<ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} />
<ActionBlock title="Actions" actions={actionList(m.actions)} />
<ActionBlock title="Reactions" actions={actionList(m.reactions)} />
<ActionBlock title="Legendary Actions" actions={actionList(m.legendary_actions)} />
</div>
);
}
function ActionBlock({ title, actions }: { title: string; actions: MonsterAction[] }) {
if (actions.length === 0) return null;
return (
<section>
<h3 className="mb-1 border-b border-line pb-1 font-display text-lg font-semibold text-ink">{title}</h3>
<div className="space-y-2">
{actions.map((a, i) => (
<p key={`${a.name}-${i}`} className="text-sm text-muted">
<strong className="text-ink">{a.name}.</strong> {a.desc}
</p>
))}
</div>
</section>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
export function DicePage() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
const [expr, setExpr] = useState('1d20');
const [last, setLast] = useState<RollResult | null>(null);
const [error, setError] = useState<string | null>(null);
const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
const roll = async (expression: string, label = '') => {
try {
const result = rollDice(expression, createRng());
setLast(result);
setError(null);
await diceRepo.add({
...(campaignId ? { campaignId } : {}),
label,
expression,
total: result.total,
breakdown: result.breakdown,
});
} catch (e) {
setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression');
}
};
return (
<Page>
<PageHeader
title="Dice"
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
actions={
history.length > 0 ? (
<Button variant="ghost" onClick={() => void diceRepo.clear(campaignId)}>
Clear history
</Button>
) : null
}
/>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div>
<div className="flex gap-2">
<Input
value={expr}
onChange={(e) => setExpr(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void roll(expr);
}}
placeholder="e.g. 2d6+3"
aria-label="Dice expression"
/>
<Button variant="primary" onClick={() => void roll(expr)}>
Roll
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{DICE.map((d) => (
<Button key={d} size="sm" onClick={() => void roll(`1${d}`)}>
{d}
</Button>
))}
<Button size="sm" variant="secondary" onClick={() => void roll('2d20kh1', 'advantage')}>
Advantage
</Button>
<Button size="sm" variant="secondary" onClick={() => void roll('2d20kl1', 'disadvantage')}>
Disadvantage
</Button>
</div>
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
{last && (
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center" aria-live="polite">
<div className="font-display text-6xl font-bold text-accent">{last.total}</div>
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
</div>
)}
</div>
<aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">History</h2>
{history.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1.5" aria-live="polite">
{history.map((h) => (
<li
key={h.id}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm"
>
<span className="text-muted">
{h.label ? `${h.label}: ` : ''}
<span className="font-mono">{h.expression}</span>
</span>
<span className="font-display text-lg font-semibold text-ink">{h.total}</span>
</li>
))}
</ul>
)}
</aside>
</div>
</Page>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge conditional class names, resolving Tailwind conflicts. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
import {
addCombatant,
applyDamage,
applyHealing,
currentCombatant,
endEncounter,
moveCombatant,
nextTurn,
previousTurn,
removeCombatant,
setTempHp,
startEncounter,
updateCombatant,
} from './engine';
function mk(id: string, initiative: number, hp = 10): Combatant {
return {
id,
name: id,
kind: 'monster',
initiative,
ac: 12,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
notes: '',
};
}
function encounter(combatants: Combatant[]): Encounter {
return {
id: 'e1', campaignId: 'c1', name: 'Test', status: 'planning',
round: 0, turnIndex: 0, combatants,
createdAt: '', updatedAt: '',
};
}
describe('initiative & turns', () => {
it('starts sorted by initiative descending at round 1', () => {
const e = startEncounter(encounter([mk('a', 5), mk('b', 20), mk('c', 12)]));
expect(e.combatants.map((c) => c.id)).toEqual(['b', 'c', 'a']);
expect(e.round).toBe(1);
expect(currentCombatant(e)?.id).toBe('b');
});
it('nextTurn wraps and increments the round', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = nextTurn(e);
expect(currentCombatant(e)?.id).toBe('b');
expect(e.round).toBe(1);
e = nextTurn(e);
expect(currentCombatant(e)?.id).toBe('a');
expect(e.round).toBe(2);
});
it('previousTurn wraps back and decrements the round', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = previousTurn(e);
expect(currentCombatant(e)?.id).toBe('b');
expect(e.round).toBe(1); // never below 1
});
});
describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', () => {
it('removing a combatant BEFORE the current turn keeps the same active combatant', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
expect(currentCombatant(e)?.id).toBe('b');
e = removeCombatant(e, 'a'); // remove someone earlier in order
expect(currentCombatant(e)?.id).toBe('b'); // still b's turn
});
it('removing the CURRENT combatant passes the turn to the next', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = removeCombatant(e, 'b');
expect(currentCombatant(e)?.id).toBe('c');
});
it('removing the last combatant in order wraps to top and bumps round', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); e = nextTurn(e); // current = c (last)
e = removeCombatant(e, 'c');
expect(currentCombatant(e)?.id).toBe('a');
expect(e.round).toBe(2);
});
it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 10)]));
e = nextTurn(e); // current = b
e = addCombatant(e, mk('c', 25)); // inserts between a and b
expect(e.combatants.map((x) => x.id)).toEqual(['a', 'c', 'b']);
expect(currentCombatant(e)?.id).toBe('b'); // unchanged
});
it('changing initiative re-sorts but keeps the turn anchored', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = updateCombatant(e, 'c', { initiative: 99 });
expect(e.combatants[0]?.id).toBe('c');
expect(currentCombatant(e)?.id).toBe('b');
});
it('moveCombatant nudges order but keeps the turn anchored', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = moveCombatant(e, 'a', 1); // swap a and b positions
expect(e.combatants.map((x) => x.id)).toEqual(['b', 'a', 'c']);
expect(currentCombatant(e)?.id).toBe('b');
});
});
describe('HP transitions', () => {
it('temp HP absorbs damage first, then current can go negative (overkill visible)', () => {
let c = setTempHp(mk('a', 0, 10), 5);
c = applyDamage(c, 12);
expect(c.hp.temp).toBe(0);
expect(c.hp.current).toBe(3); // 10 - (12-5)
c = applyDamage(c, 20);
expect(c.hp.current).toBe(-17); // overkill tracked, not clamped to 0
});
it('temp HP does not stack — keeps the higher value', () => {
let c = setTempHp(mk('a', 0, 10), 5);
c = setTempHp(c, 3);
expect(c.hp.temp).toBe(5);
c = setTempHp(c, 8);
expect(c.hp.temp).toBe(8);
});
it('healing never exceeds max', () => {
let c = mk('a', 0, 10);
c = applyDamage(c, 6);
c = applyHealing(c, 100);
expect(c.hp.current).toBe(10);
});
it('ignores NaN/negative inputs', () => {
const c = mk('a', 0, 10);
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
expect(applyHealing(c, -5).hp.current).toBe(10);
});
});
describe('endEncounter', () => {
it('marks the encounter ended', () => {
expect(endEncounter(encounter([])).status).toBe('ended');
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
/**
* Pure combat-state transitions. Every function returns a NEW encounter and
* never mutates its input. The turn pointer is tracked by combatant *identity*,
* so adding/removing/reordering combatants mid-fight never silently changes
* whose turn it is — the bug cluster (C16C19) that the old app never fixed.
*/
export function currentCombatant(enc: Encounter): Combatant | undefined {
return enc.combatants[enc.turnIndex];
}
/** Sort by initiative (desc). JS sort is stable, so ties keep insertion order. */
export function sortByInitiative(combatants: Combatant[]): Combatant[] {
return [...combatants].sort((a, b) => b.initiative - a.initiative);
}
function indexOfId(combatants: Combatant[], id: string | undefined): number {
if (!id) return -1;
return combatants.findIndex((c) => c.id === id);
}
/** Reorder by initiative and start at the top of the order, round 1. */
export function startEncounter(enc: Encounter): Encounter {
return {
...enc,
status: 'active',
round: enc.combatants.length > 0 ? 1 : 0,
turnIndex: 0,
combatants: sortByInitiative(enc.combatants),
};
}
export function endEncounter(enc: Encounter): Encounter {
return { ...enc, status: 'ended' };
}
/** Advance to the next combatant; wrapping past the end increments the round. */
export function nextTurn(enc: Encounter): Encounter {
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
const atEnd = enc.turnIndex >= enc.combatants.length - 1;
return {
...enc,
turnIndex: atEnd ? 0 : enc.turnIndex + 1,
round: atEnd ? enc.round + 1 : enc.round,
};
}
/** Step back a turn; wrapping before the start decrements the round (min 1). */
export function previousTurn(enc: Encounter): Encounter {
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
const atStart = enc.turnIndex <= 0;
return {
...enc,
turnIndex: atStart ? enc.combatants.length - 1 : enc.turnIndex - 1,
round: atStart ? Math.max(1, enc.round - 1) : enc.round,
};
}
/**
* Insert a combatant. When the encounter is active, the new combatant is placed
* by initiative and the turn pointer is corrected so it still points at the
* same combatant whose turn it is.
*/
export function addCombatant(enc: Encounter, combatant: Combatant): Encounter {
if (enc.status !== 'active') {
return { ...enc, combatants: [...enc.combatants, combatant] };
}
const currentId = currentCombatant(enc)?.id;
const combatants = sortByInitiative([...enc.combatants, combatant]);
const turnIndex = Math.max(0, indexOfId(combatants, currentId));
return { ...enc, combatants, turnIndex };
}
/** Remove a combatant, keeping the turn on the correct next combatant. */
export function removeCombatant(enc: Encounter, id: string): Encounter {
const removedIndex = indexOfId(enc.combatants, id);
if (removedIndex === -1) return enc;
const combatants = enc.combatants.filter((c) => c.id !== id);
if (combatants.length === 0) return { ...enc, combatants, turnIndex: 0 };
let turnIndex = enc.turnIndex;
let round = enc.round;
if (removedIndex < enc.turnIndex) {
// someone before the current turn left: shift pointer left
turnIndex = enc.turnIndex - 1;
} else if (removedIndex === enc.turnIndex) {
// current combatant left: turn passes to whoever now sits at this index
if (turnIndex >= combatants.length) {
turnIndex = 0;
if (enc.status === 'active') round += 1;
}
}
return { ...enc, combatants, turnIndex, round };
}
/** Patch a combatant by id. Re-sorts (and re-anchors the turn) if initiative changed. */
export function updateCombatant(enc: Encounter, id: string, patch: Partial<Combatant>): Encounter {
const idx = indexOfId(enc.combatants, id);
if (idx === -1) return enc;
const updated = enc.combatants.map((c) => (c.id === id ? { ...c, ...patch } : c));
if (patch.initiative !== undefined && enc.status === 'active') {
const currentId = currentCombatant(enc)?.id;
const combatants = sortByInitiative(updated);
return { ...enc, combatants, turnIndex: Math.max(0, indexOfId(combatants, currentId)) };
}
return { ...enc, combatants: updated };
}
/** Manually nudge a combatant up/down the order, keeping the turn anchored. */
export function moveCombatant(enc: Encounter, id: string, direction: -1 | 1): Encounter {
const idx = indexOfId(enc.combatants, id);
if (idx === -1) return enc;
const target = idx + direction;
if (target < 0 || target >= enc.combatants.length) return enc;
const currentId = currentCombatant(enc)?.id;
const combatants = [...enc.combatants];
[combatants[idx], combatants[target]] = [combatants[target]!, combatants[idx]!];
return { ...enc, combatants, turnIndex: Math.max(0, indexOfId(combatants, currentId)) };
}
// ---------------- HP transitions ----------------
/** Apply damage: temporary HP absorbs first, then current (may go negative). */
export function applyDamage(c: Combatant, amount: number): Combatant {
if (!Number.isFinite(amount) || amount <= 0) return c;
const absorbed = Math.min(c.hp.temp, amount);
const remaining = amount - absorbed;
return {
...c,
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
};
}
/** Heal up to max. Does not touch temporary HP. */
export function applyHealing(c: Combatant, amount: number): Combatant {
if (!Number.isFinite(amount) || amount <= 0) return c;
return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } };
}
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
export function setTempHp(c: Combatant, temp: number): Combatant {
if (!Number.isFinite(temp) || temp < 0) return c;
return { ...c, hp: { ...c.hp, temp: Math.max(c.hp.temp, temp) } };
}
+56
View File
@@ -0,0 +1,56 @@
import type { Monster, Spell, MagicItem } from './types';
/**
* Lazy, cached loaders for the SRD datasets. Dynamic `import()` keeps the
* multi-hundred-KB JSON out of the main bundle — it loads only when the user
* opens the compendium (fixing the old app's eager MB-scale imports).
*/
let monstersCache: Monster[] | null = null;
let spellsCache: Spell[] | null = null;
let itemsCache: MagicItem[] | null = null;
export async function loadMonsters(): Promise<Monster[]> {
if (!monstersCache) {
const mod = await import('@/data/srd/monsters-srd.json');
monstersCache = mod.default as unknown as Monster[];
}
return monstersCache;
}
export async function loadSpells(): Promise<Spell[]> {
if (!spellsCache) {
const mod = await import('@/data/srd/spells-srd.json');
spellsCache = mod.default as unknown as Spell[];
}
return spellsCache;
}
export async function loadItems(): Promise<MagicItem[]> {
if (!itemsCache) {
const mod = await import('@/data/srd/magicitems-srd.json');
itemsCache = mod.default as unknown as MagicItem[];
}
return itemsCache;
}
/** Build a combat-ready stat block summary from a monster. */
export function monsterToCombatant(m: Monster): {
name: string;
ac: number;
hp: number;
} {
return {
name: m.name,
ac: m.armor_class ?? 10,
hp: m.hit_points ?? 1,
};
}
export function crLabel(m: Monster): string {
if (m.challenge_rating) return m.challenge_rating;
if (m.cr === undefined) return '—';
if (m.cr === 0.125) return '1/8';
if (m.cr === 0.25) return '1/4';
if (m.cr === 0.5) return '1/2';
return String(m.cr);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Normalized shapes over the Open5e SRD data. Fields mirror the source JSON;
* optional because the dataset is not perfectly uniform.
*/
export interface MonsterAction {
name: string;
desc: string;
attack_bonus?: number;
damage_dice?: string;
damage_bonus?: number;
}
export interface Monster {
slug: string;
name: string;
size?: string;
type?: string;
subtype?: string;
alignment?: string;
armor_class?: number;
armor_desc?: string | null;
hit_points?: number;
hit_dice?: string;
speed?: Record<string, number> | { walk?: number; [k: string]: number | undefined };
strength?: number;
dexterity?: number;
constitution?: number;
intelligence?: number;
wisdom?: number;
charisma?: number;
perception?: number;
senses?: string;
languages?: string;
/** numeric challenge rating (0.25 for "1/4") */
cr?: number;
challenge_rating?: string;
actions?: MonsterAction[] | string;
special_abilities?: MonsterAction[] | string;
legendary_actions?: MonsterAction[] | string;
reactions?: MonsterAction[] | string;
damage_vulnerabilities?: string;
damage_resistances?: string;
damage_immunities?: string;
condition_immunities?: string;
desc?: string;
}
export interface Spell {
slug: string;
name: string;
desc?: string;
higher_level?: string;
level?: string;
level_int?: number;
school?: string;
casting_time?: string;
range?: string;
duration?: string;
concentration?: boolean | string;
ritual?: boolean | string;
components?: string;
material?: string;
dnd_class?: string;
}
export interface MagicItem {
slug: string;
name: string;
type?: string;
desc?: string;
rarity?: string;
requires_attunement?: string;
}
export type CompendiumKind = 'monsters' | 'spells' | 'items';
+32
View File
@@ -0,0 +1,32 @@
import Dexie, { type EntityTable } from 'dexie';
import type { Campaign } from '@/lib/schemas/campaign';
import type { Character } from '@/lib/schemas/character';
import type { Encounter } from '@/lib/schemas/encounter';
import type { DiceRoll } from '@/lib/schemas/dice';
/**
* Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions
* correctly (commits before resolving), which removes the silent-data-loss
* class of bugs the old hand-rolled adapter shipped with.
*
* Schema versions are additive: bump `version(n)` and add a `.upgrade()` when
* the shape changes — never edit a past version in place.
*/
export class TtrpgDatabase extends Dexie {
campaigns!: EntityTable<Campaign, 'id'>;
characters!: EntityTable<Character, 'id'>;
encounters!: EntityTable<Encounter, 'id'>;
diceRolls!: EntityTable<DiceRoll, 'id'>;
constructor() {
super('ttrpg-manager');
this.version(1).stores({
campaigns: 'id, updatedAt',
characters: 'id, campaignId, kind',
encounters: 'id, campaignId, status',
diceRolls: 'id, campaignId, createdAt',
});
}
}
export const db = new TtrpgDatabase();
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from './db';
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo } from './repositories';
beforeEach(async () => {
await db.delete();
await db.open();
});
describe('campaign cascade delete', () => {
it('removes all child entities atomically (old app orphaned them forever)', async () => {
const a = await campaignsRepo.create({ name: 'Curse of Strahd', system: '5e', description: '' });
const b = await campaignsRepo.create({ name: 'Other', system: 'pf2e', description: '' });
await charactersRepo.create(a.id, {
system: '5e', name: 'Ezmerelda', kind: 'pc', ancestry: 'Human', className: 'Ranger', level: 5,
});
await encountersRepo.create(a.id, 'Death House');
await diceRepo.add({ campaignId: a.id, label: 'attack', expression: '1d20', total: 14, breakdown: '' });
// unrelated campaign's data must survive
await charactersRepo.create(b.id, {
system: 'pf2e', name: 'Keep me', kind: 'pc', ancestry: 'Elf', className: 'Wizard', level: 1,
});
await campaignsRepo.remove(a.id);
expect(await campaignsRepo.get(a.id)).toBeUndefined();
expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0);
expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0);
expect(await diceRepo.recent(a.id)).toHaveLength(0);
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
});
});
describe('validation on write', () => {
it('rejects NaN HP', async () => {
const c = await campaignsRepo.create({ name: 'C', system: '5e', description: '' });
const enc = await encountersRepo.create(c.id, 'E');
enc.combatants.push({
id: 'x', name: 'Goblin', kind: 'monster', initiative: 12, ac: 15,
hp: { current: Number.NaN, max: 7, temp: 0 }, conditions: [], notes: '',
});
await expect(encountersRepo.save(enc)).rejects.toThrow();
});
});
+168
View File
@@ -0,0 +1,168 @@
import { db } from './db';
import { newId } from '@/lib/ids';
import {
campaignSchema,
characterSchema,
encounterSchema,
diceRollSchema,
type Campaign,
type CampaignDraft,
type Character,
type Encounter,
type DiceRoll,
} from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
function now(): string {
return new Date().toISOString();
}
// ---------------- Campaigns ----------------
export const campaignsRepo = {
async list(): Promise<Campaign[]> {
const all = await db.campaigns.toArray();
return all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
},
get(id: string): Promise<Campaign | undefined> {
return db.campaigns.get(id);
},
async create(draft: CampaignDraft): Promise<Campaign> {
const ts = now();
const campaign = campaignSchema.parse({
...draft,
id: newId(),
description: draft.description ?? '',
createdAt: ts,
updatedAt: ts,
});
await db.campaigns.add(campaign);
return campaign;
},
async update(id: string, patch: Partial<Omit<Campaign, 'id' | 'createdAt'>>): Promise<void> {
await db.campaigns.update(id, { ...patch, updatedAt: now() });
},
/** Delete a campaign and ALL its children atomically (real cascade). */
async remove(id: string): Promise<void> {
await db.transaction('rw', db.campaigns, db.characters, db.encounters, db.diceRolls, async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
await db.diceRolls.where('campaignId').equals(id).delete();
await db.campaigns.delete(id);
});
},
};
// ---------------- Characters ----------------
export const charactersRepo = {
listByCampaign(campaignId: string): Promise<Character[]> {
return db.characters.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Character | undefined> {
return db.characters.get(id);
},
async create(
campaignId: string,
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
system: Character['system'];
},
): Promise<Character> {
const ts = now();
const sys = getSystem(draft.system);
const character = characterSchema.parse({
id: newId(),
campaignId,
system: draft.system,
kind: draft.kind,
name: draft.name,
ancestry: draft.ancestry,
className: draft.className,
level: draft.level,
abilities: sys.defaultAbilityScores(),
hp: { current: 1, max: 1, temp: 0 },
speed: 30,
armorBonus: 0,
skillRanks: {},
saveRanks: {},
perceptionRank: 'trained',
notes: '',
createdAt: ts,
updatedAt: ts,
});
await db.characters.add(character);
return character;
},
async update(id: string, patch: Partial<Omit<Character, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.characters.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.characters.delete(id);
},
};
// ---------------- Encounters ----------------
export const encountersRepo = {
listByCampaign(campaignId: string): Promise<Encounter[]> {
return db.encounters.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Encounter | undefined> {
return db.encounters.get(id);
},
async create(campaignId: string, name: string): Promise<Encounter> {
const ts = now();
const encounter = encounterSchema.parse({
id: newId(),
campaignId,
name,
status: 'planning',
round: 0,
turnIndex: 0,
combatants: [],
createdAt: ts,
updatedAt: ts,
});
await db.encounters.add(encounter);
return encounter;
},
/** Persist a full encounter (combat state lives as one document). */
async save(encounter: Encounter): Promise<void> {
const validated = encounterSchema.parse({ ...encounter, updatedAt: now() });
await db.encounters.put(validated);
},
async remove(id: string): Promise<void> {
await db.encounters.delete(id);
},
};
// ---------------- Dice rolls ----------------
export const diceRepo = {
async recent(campaignId: string | undefined, limit = 50): Promise<DiceRoll[]> {
const coll = campaignId
? db.diceRolls.where('campaignId').equals(campaignId)
: db.diceRolls.toCollection();
const all = await coll.toArray();
return all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
},
async add(roll: Omit<DiceRoll, 'id' | 'createdAt'>): Promise<DiceRoll> {
const record = diceRollSchema.parse({ ...roll, id: newId(), createdAt: now() });
await db.diceRolls.add(record);
return record;
},
async clear(campaignId?: string): Promise<void> {
if (campaignId) await db.diceRolls.where('campaignId').equals(campaignId).delete();
else await db.diceRolls.clear();
},
};
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { parseDice, rollDice, DiceParseError } from './notation';
import { createRng } from '@/lib/rng';
describe('parseDice', () => {
it('parses a simple die group', () => {
expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]);
});
it('defaults count to 1', () => {
expect(parseDice('d20')).toEqual([{ kind: 'dice', sign: 1, count: 1, sides: 20 }]);
});
it('parses mixed terms with signs', () => {
expect(parseDice('2d6 + 1d4 - 2')).toEqual([
{ kind: 'dice', sign: 1, count: 2, sides: 6 },
{ kind: 'dice', sign: 1, count: 1, sides: 4 },
{ kind: 'const', sign: -1, value: 2 },
]);
});
it('parses keep-highest', () => {
expect(parseDice('4d6kh3')).toEqual([
{ kind: 'dice', sign: 1, count: 4, sides: 6, keep: { op: 'kh', n: 3 } },
]);
});
it.each(['', 'abc', '3d', 'd', '1d6+', '0d6', '3d6kh4', '1d1'])(
'rejects malformed input %s',
(expr) => {
expect(() => parseDice(expr)).toThrow(DiceParseError);
},
);
});
describe('rollDice', () => {
it('is deterministic for a given seed', () => {
const a = rollDice('3d6+2', createRng('seed-1'));
const b = rollDice('3d6+2', createRng('seed-1'));
expect(a.total).toBe(b.total);
expect(a.terms[0]!.dice.map((d) => d.value)).toEqual(b.terms[0]!.dice.map((d) => d.value));
});
it('stays within bounds', () => {
for (let i = 0; i < 200; i++) {
const r = rollDice('1d20', createRng(i));
expect(r.total).toBeGreaterThanOrEqual(1);
expect(r.total).toBeLessThanOrEqual(20);
}
});
it('keep-highest drops the lowest dice and excludes them from the total', () => {
const r = rollDice('4d6kh3', createRng('chargen'));
const dropped = r.terms[0]!.dice.filter((d) => !d.kept);
expect(dropped).toHaveLength(1);
const keptSum = r.terms[0]!.dice.filter((d) => d.kept).reduce((s, d) => s + d.value, 0);
expect(r.total).toBe(keptSum);
// the dropped die is <= every kept die
const minKept = Math.min(...r.terms[0]!.dice.filter((d) => d.kept).map((d) => d.value));
expect(dropped[0]!.value).toBeLessThanOrEqual(minKept);
});
it('applies constant modifiers', () => {
const r = rollDice('1d6+5', createRng(1));
expect(r.total).toBe(r.terms[0]!.dice[0]!.value + 5);
});
});
+171
View File
@@ -0,0 +1,171 @@
import type { Rng } from '@/lib/rng';
import { createRng } from '@/lib/rng';
/** A single die-group term, e.g. `4d6kh3`. */
export interface DiceGroup {
kind: 'dice';
sign: 1 | -1;
count: number;
sides: number;
/** keep/drop highest/lowest, e.g. { op: 'kh', n: 3 } */
keep?: { op: 'kh' | 'kl' | 'dh' | 'dl'; n: number };
}
/** A flat constant term, e.g. `+3`. */
export interface ConstantTerm {
kind: 'const';
sign: 1 | -1;
value: number;
}
export type DiceTerm = DiceGroup | ConstantTerm;
export interface RolledDie {
sides: number;
value: number;
/** false when dropped by a keep/drop modifier */
kept: boolean;
}
export interface TermResult {
term: DiceTerm;
dice: RolledDie[];
/** signed subtotal contributed by this term */
subtotal: number;
}
export interface RollResult {
expression: string;
total: number;
terms: TermResult[];
/** human-readable breakdown, e.g. "2d20kh1 [18, 5→18] + 3 = 21" */
breakdown: string;
}
export class DiceParseError extends Error {}
const MAX_DICE = 1000;
const MAX_SIDES = 1000;
const TERM_RE =
/^(\d*)d(\d+)(?:(kh|kl|dh|dl)(\d+))?$/i;
/**
* Parse a dice expression like `2d6+1d4+3` or `4d6kh3` into terms.
* Throws DiceParseError on malformed input — never silently coerces to NaN
* (a whole class of bugs in the old app).
*/
export function parseDice(expression: string): DiceTerm[] {
const cleaned = expression.replace(/\s+/g, '');
if (cleaned === '') throw new DiceParseError('Empty expression');
// Split into signed chunks: a leading sign is optional.
const chunks = cleaned.match(/[+-]?[^+-]+/g);
// Reconstructing must reproduce the input exactly; otherwise a dangling or
// doubled operator (e.g. "1d6+", "1d6++2") was silently dropped.
if (!chunks || chunks.join('') !== cleaned) {
throw new DiceParseError(`Cannot parse "${expression}"`);
}
const terms: DiceTerm[] = [];
for (const raw of chunks) {
let sign: 1 | -1 = 1;
let body = raw;
if (body.startsWith('+')) body = body.slice(1);
else if (body.startsWith('-')) {
sign = -1;
body = body.slice(1);
}
if (body === '') throw new DiceParseError(`Dangling sign in "${expression}"`);
if (/^\d+$/.test(body)) {
terms.push({ kind: 'const', sign, value: Number(body) });
continue;
}
const m = TERM_RE.exec(body);
if (!m) throw new DiceParseError(`Invalid term "${raw}" in "${expression}"`);
const count = m[1] === '' ? 1 : Number(m[1]);
const sides = Number(m[2]);
if (count < 1 || count > MAX_DICE) throw new DiceParseError(`Dice count out of range in "${raw}"`);
if (sides < 2 || sides > MAX_SIDES) throw new DiceParseError(`Die size out of range in "${raw}"`);
let keep: DiceGroup['keep'];
if (m[3]) {
const n = Number(m[4]);
if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`);
keep = { op: m[3].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n };
}
terms.push({ kind: 'dice', sign, count, sides, ...(keep ? { keep } : {}) });
}
return terms;
}
function applyKeep(values: number[], keep: DiceGroup['keep']): boolean[] {
const kept = values.map(() => true);
if (!keep) return kept;
// index list sorted by value
const order = values.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
let dropIdx: number[] = [];
if (keep.op === 'kh') dropIdx = order.slice(0, values.length - keep.n).map((o) => o.i);
else if (keep.op === 'kl') dropIdx = order.slice(keep.n).map((o) => o.i);
else if (keep.op === 'dl') dropIdx = order.slice(0, keep.n).map((o) => o.i);
else if (keep.op === 'dh') dropIdx = order.slice(values.length - keep.n).map((o) => o.i);
for (const i of dropIdx) kept[i] = false;
return kept;
}
/** Roll a parsed/string expression. Pass an Rng for deterministic results. */
export function rollDice(expression: string, rng: Rng = createRng()): RollResult {
const terms = parseDice(expression);
const termResults: TermResult[] = [];
let total = 0;
for (const term of terms) {
if (term.kind === 'const') {
const subtotal = term.sign * term.value;
total += subtotal;
termResults.push({ term, dice: [], subtotal });
continue;
}
const values: number[] = [];
for (let i = 0; i < term.count; i++) values.push(rng.int(1, term.sides));
const kept = applyKeep(values, term.keep);
const dice: RolledDie[] = values.map((value, i) => ({
sides: term.sides,
value,
kept: kept[i] ?? true,
}));
const sum = dice.reduce((acc, d) => acc + (d.kept ? d.value : 0), 0);
const subtotal = term.sign * sum;
total += subtotal;
termResults.push({ term, dice, subtotal });
}
return {
expression,
total,
terms: termResults,
breakdown: formatBreakdown(termResults, total),
};
}
function formatTerm(tr: TermResult): string {
const { term } = tr;
if (term.kind === 'const') return String(term.value);
const keep = term.keep ? `${term.keep.op}${term.keep.n}` : '';
const label = `${term.count}d${term.sides}${keep}`;
const dice = tr.dice
.map((d) => (d.kept ? String(d.value) : `~~${d.value}~~`))
.join(', ');
return `${label} [${dice}]`;
}
function formatBreakdown(terms: TermResult[], total: number): string {
let out = '';
terms.forEach((tr, idx) => {
const sign = tr.term.sign < 0 ? ' - ' : idx === 0 ? '' : ' + ';
out += sign + formatTerm(tr);
});
return `${out} = ${total}`;
}
+4
View File
@@ -0,0 +1,4 @@
/** Format a modifier with an explicit sign, e.g. 3 -> "+3", -1 -> "-1". */
export function formatModifier(n: number): string {
return n >= 0 ? `+${n}` : String(n);
}
+6
View File
@@ -0,0 +1,6 @@
import { nanoid } from 'nanoid';
/** Generate a collision-resistant unique id. Single strategy app-wide. */
export function newId(): string {
return nanoid(16);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Seedable pseudo-random number generator.
*
* The old app mixed `Math.random()` into game rolls, which made results
* untestable and non-reproducible. Everything that rolls dice goes through an
* `Rng` instance instead, so callers can pass a seed for deterministic tests
* and reproducible "shared seed" sessions.
*/
export interface Rng {
/** Float in [0, 1). */
next(): number;
/** Integer in [min, max] inclusive. */
int(min: number, max: number): number;
}
/** Hash a string seed into a 32-bit integer (xfnv1a). */
function hashSeed(str: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 16777619);
}
return h >>> 0;
}
/** mulberry32 — small, fast, well-distributed PRNG. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Create a deterministic RNG from a seed. With no seed, derives one from
* crypto for genuine unpredictability while keeping the same interface.
*/
export function createRng(seed?: string | number): Rng {
let intSeed: number;
if (seed === undefined) {
intSeed = (globalThis.crypto?.getRandomValues(new Uint32Array(1))[0] ?? Date.now()) >>> 0;
} else if (typeof seed === 'number') {
intSeed = seed >>> 0;
} else {
intSeed = hashSeed(seed);
}
const gen = mulberry32(intSeed);
return {
next: gen,
int(min: number, max: number): number {
if (max < min) [min, max] = [max, min];
return min + Math.floor(gen() * (max - min + 1));
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { AbilityKey, AbilityScores } from './types';
import { ABILITY_KEYS } from './types';
/** Shared d20 ability modifier. Clamps absurd inputs rather than returning NaN. */
export function abilityModifier(score: number): number {
if (!Number.isFinite(score)) return 0;
return Math.floor((score - 10) / 2);
}
export function defaultAbilityScores(): AbilityScores {
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
}
export const ABILITY_LABELS: Record<AbilityKey, string> = {
str: 'Strength',
dex: 'Dexterity',
con: 'Constitution',
int: 'Intelligence',
wis: 'Wisdom',
cha: 'Charisma',
};
export const ABILITY_ABBR: Record<AbilityKey, string> = {
str: 'STR',
dex: 'DEX',
con: 'CON',
int: 'INT',
wis: 'WIS',
cha: 'CHA',
};
export { ABILITY_KEYS };
+84
View File
@@ -0,0 +1,84 @@
import type {
AbilityKey,
CharacterRulesInput,
DerivedSkill,
ProficiencyRank,
RulesSystem,
} from '../types';
import { ABILITY_KEYS } from '../types';
import {
ABILITY_ABBR,
ABILITY_LABELS,
abilityModifier,
defaultAbilityScores,
} from '../abilities';
import { DND5E_SKILLS } from './skills';
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
export function proficiencyBonus(level: number): number {
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
return 2 + Math.floor((lvl - 1) / 4);
}
/**
* In 5e a skill/save is either proficient or not, with "expert" meaning
* expertise (double proficiency). We map PF2e-style ranks onto that.
*/
function profMultiplier(rank: ProficiencyRank): number {
if (rank === 'untrained') return 0;
if (rank === 'trained') return 1;
return 2; // expert/master/legendary => expertise
}
export const dnd5e: RulesSystem = {
id: '5e',
label: 'D&D 5e',
abilities: ABILITY_KEYS,
abilityLabels: ABILITY_LABELS,
skills: DND5E_SKILLS,
abilityModifier,
defaultAbilityScores,
proficiencyValue(level, rank) {
return proficiencyBonus(level) * profMultiplier(rank);
},
initiativeModifier({ abilities }) {
return abilityModifier(abilities.dex);
},
skillModifiers(input: CharacterRulesInput): DerivedSkill[] {
const pb = proficiencyBonus(input.level);
return DND5E_SKILLS.map((s) => {
const rank = input.skillRanks?.[s.key] ?? 'untrained';
return {
key: s.key,
label: s.label,
ability: s.ability,
modifier: abilityModifier(input.abilities[s.ability]) + pb * profMultiplier(rank),
};
});
},
saveModifiers(input): Record<AbilityKey, number> {
const pb = proficiencyBonus(input.level);
const out = {} as Record<AbilityKey, number>;
for (const a of ABILITY_KEYS) {
const rank = input.saveRanks?.[a] ?? 'untrained';
out[a] = abilityModifier(input.abilities[a]) + (rank !== 'untrained' ? pb : 0);
}
return out;
},
baseArmorClass(input) {
// Unarmored AC. Heavy armor etc. is represented as armorBonus on top.
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
},
spellSaveDc(input, ability) {
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
},
};
export { ABILITY_ABBR };
+22
View File
@@ -0,0 +1,22 @@
import type { SkillDef } from '../types';
export const DND5E_SKILLS: readonly SkillDef[] = [
{ key: 'acrobatics', label: 'Acrobatics', ability: 'dex' },
{ key: 'animal-handling', label: 'Animal Handling', ability: 'wis' },
{ key: 'arcana', label: 'Arcana', ability: 'int' },
{ key: 'athletics', label: 'Athletics', ability: 'str' },
{ key: 'deception', label: 'Deception', ability: 'cha' },
{ key: 'history', label: 'History', ability: 'int' },
{ key: 'insight', label: 'Insight', ability: 'wis' },
{ key: 'intimidation', label: 'Intimidation', ability: 'cha' },
{ key: 'investigation', label: 'Investigation', ability: 'int' },
{ key: 'medicine', label: 'Medicine', ability: 'wis' },
{ key: 'nature', label: 'Nature', ability: 'int' },
{ key: 'perception', label: 'Perception', ability: 'wis' },
{ key: 'performance', label: 'Performance', ability: 'cha' },
{ key: 'persuasion', label: 'Persuasion', ability: 'cha' },
{ key: 'religion', label: 'Religion', ability: 'int' },
{ key: 'sleight-of-hand', label: 'Sleight of Hand', ability: 'dex' },
{ key: 'stealth', label: 'Stealth', ability: 'dex' },
{ key: 'survival', label: 'Survival', ability: 'wis' },
];
+20
View File
@@ -0,0 +1,20 @@
import type { RulesSystem, SystemId } from './types';
import { dnd5e } from './dnd5e';
import { pf2e } from './pf2e';
const SYSTEMS: Record<SystemId, RulesSystem> = {
'5e': dnd5e,
pf2e,
};
export function getSystem(id: SystemId): RulesSystem {
return SYSTEMS[id];
}
export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
{ id: '5e', label: dnd5e.label },
{ id: 'pf2e', label: pf2e.label },
];
export * from './types';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS } from './abilities';
+78
View File
@@ -0,0 +1,78 @@
import type {
AbilityKey,
CharacterRulesInput,
DerivedSkill,
ProficiencyRank,
RulesSystem,
} from '../types';
import { ABILITY_KEYS } from '../types';
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores } from '../abilities';
import { PF2E_SKILLS } from './skills';
const RANK_BONUS: Record<ProficiencyRank, number> = {
untrained: 0,
trained: 2,
expert: 4,
master: 6,
legendary: 8,
};
/**
* PF2e proficiency adds your level only when trained or better, plus the rank
* bonus. Untrained = 0 (no level). This is the core math difference from 5e.
*/
export function pf2eProficiency(level: number, rank: ProficiencyRank): number {
if (rank === 'untrained') return 0;
const lvl = Math.max(1, Math.floor(level) || 1);
return lvl + RANK_BONUS[rank];
}
export const pf2e: RulesSystem = {
id: 'pf2e',
label: 'Pathfinder 2e',
abilities: ABILITY_KEYS,
abilityLabels: ABILITY_LABELS,
skills: PF2E_SKILLS,
abilityModifier,
defaultAbilityScores,
proficiencyValue: pf2eProficiency,
initiativeModifier(input) {
// Initiative in PF2e is usually a Perception check.
return abilityModifier(input.abilities.wis) + pf2eProficiency(input.level, input.perceptionRank ?? 'trained');
},
skillModifiers(input: CharacterRulesInput): DerivedSkill[] {
return PF2E_SKILLS.map((s) => {
const rank = input.skillRanks?.[s.key] ?? 'untrained';
return {
key: s.key,
label: s.label,
ability: s.ability,
modifier: abilityModifier(input.abilities[s.ability]) + pf2eProficiency(input.level, rank),
};
});
},
saveModifiers(input): Record<AbilityKey, number> {
const out = {} as Record<AbilityKey, number>;
for (const a of ABILITY_KEYS) {
// Only CON (Fort), DEX (Ref), WIS (Will) are real saves in PF2e.
const isSave = a === 'con' || a === 'dex' || a === 'wis';
const rank = input.saveRanks?.[a] ?? 'untrained';
out[a] = abilityModifier(input.abilities[a]) + (isSave ? pf2eProficiency(input.level, rank) : 0);
}
return out;
},
baseArmorClass(input) {
// 10 + dex + (item/proficiency folded into armorBonus for the MVP sheet).
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
},
spellSaveDc(input, ability) {
return 10 + abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
},
};
+27
View File
@@ -0,0 +1,27 @@
import type { SkillDef } from '../types';
export const PF2E_SKILLS: readonly SkillDef[] = [
{ key: 'acrobatics', label: 'Acrobatics', ability: 'dex' },
{ key: 'arcana', label: 'Arcana', ability: 'int' },
{ key: 'athletics', label: 'Athletics', ability: 'str' },
{ key: 'crafting', label: 'Crafting', ability: 'int' },
{ key: 'deception', label: 'Deception', ability: 'cha' },
{ key: 'diplomacy', label: 'Diplomacy', ability: 'cha' },
{ key: 'intimidation', label: 'Intimidation', ability: 'cha' },
{ key: 'medicine', label: 'Medicine', ability: 'wis' },
{ key: 'nature', label: 'Nature', ability: 'wis' },
{ key: 'occultism', label: 'Occultism', ability: 'int' },
{ key: 'performance', label: 'Performance', ability: 'cha' },
{ key: 'religion', label: 'Religion', ability: 'wis' },
{ key: 'society', label: 'Society', ability: 'int' },
{ key: 'stealth', label: 'Stealth', ability: 'dex' },
{ key: 'survival', label: 'Survival', ability: 'wis' },
{ key: 'thievery', label: 'Thievery', ability: 'dex' },
];
/** PF2e's three saving throws and the ability each keys off. */
export const PF2E_SAVES = [
{ key: 'fortitude', label: 'Fortitude', ability: 'con' as const },
{ key: 'reflex', label: 'Reflex', ability: 'dex' as const },
{ key: 'will', label: 'Will', ability: 'wis' as const },
];
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { abilityModifier } from './abilities';
import { dnd5e, proficiencyBonus } from './dnd5e';
import { pf2e, pf2eProficiency } from './pf2e';
import type { CharacterRulesInput } from './types';
describe('abilityModifier', () => {
it.each([
[10, 0],
[11, 0],
[12, 1],
[8, -1],
[20, 5],
[1, -5],
[30, 10],
])('score %i -> %i', (score, mod) => {
expect(abilityModifier(score)).toBe(mod);
});
it('never returns NaN for garbage input', () => {
expect(abilityModifier(Number.NaN)).toBe(0);
expect(abilityModifier(Infinity)).toBe(0);
});
});
describe('5e proficiency bonus', () => {
it.each([
[1, 2],
[4, 2],
[5, 3],
[9, 4],
[13, 5],
[17, 6],
[20, 6],
])('level %i -> +%i', (lvl, pb) => {
expect(proficiencyBonus(lvl)).toBe(pb);
});
it('clamps out-of-range levels (old app gave +251 at level 1000)', () => {
expect(proficiencyBonus(1000)).toBe(6);
expect(proficiencyBonus(0)).toBe(2);
expect(proficiencyBonus(-5)).toBe(2);
});
});
describe('5e derived stats', () => {
const input: CharacterRulesInput = {
level: 5,
abilities: { str: 16, dex: 14, con: 14, int: 10, wis: 12, cha: 8 },
skillRanks: { athletics: 'trained', stealth: 'expert' },
saveRanks: { str: 'trained', con: 'trained' },
};
it('adds proficiency to trained skills and double for expertise', () => {
const skills = dnd5e.skillModifiers(input);
const athletics = skills.find((s) => s.key === 'athletics')!;
const stealth = skills.find((s) => s.key === 'stealth')!;
const arcana = skills.find((s) => s.key === 'arcana')!;
expect(athletics.modifier).toBe(3 + 3); // str mod + PB(5)=3
expect(stealth.modifier).toBe(2 + 6); // dex mod + 2*PB
expect(arcana.modifier).toBe(0); // untrained int
});
it('computes spell save DC', () => {
expect(dnd5e.spellSaveDc!(input, 'wis')).toBe(8 + 3 + 1);
});
it('unarmored AC does not penalise below 10 + dex', () => {
// regression: old app applied a phantom -1 from heavy armor DEX cap
const ac = dnd5e.baseArmorClass(input);
expect(ac).toBe(10 + 2);
});
});
describe('pf2e proficiency', () => {
it('adds level + rank bonus when trained, 0 when untrained', () => {
expect(pf2eProficiency(5, 'untrained')).toBe(0);
expect(pf2eProficiency(5, 'trained')).toBe(5 + 2);
expect(pf2eProficiency(5, 'expert')).toBe(5 + 4);
expect(pf2eProficiency(10, 'legendary')).toBe(10 + 8);
});
it('skill modifier folds in ability + proficiency', () => {
const skills = pf2e.skillModifiers({
level: 3,
abilities: { str: 18, dex: 10, con: 12, int: 10, wis: 14, cha: 10 },
skillRanks: { athletics: 'expert' },
});
const athletics = skills.find((s) => s.key === 'athletics')!;
expect(athletics.modifier).toBe(4 + (3 + 4)); // str mod + (level + expert bonus)
});
it('only con/dex/wis are real saves', () => {
const saves = pf2e.saveModifiers({
level: 4,
abilities: { str: 10, dex: 14, con: 16, int: 10, wis: 12, cha: 10 },
saveRanks: { con: 'expert', dex: 'trained', wis: 'trained' },
});
expect(saves.con).toBe(3 + (4 + 4));
expect(saves.dex).toBe(2 + (4 + 2));
expect(saves.str).toBe(0); // not a save: just ability mod
});
});
+74
View File
@@ -0,0 +1,74 @@
export type SystemId = '5e' | 'pf2e';
export const ABILITY_KEYS = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const;
export type AbilityKey = (typeof ABILITY_KEYS)[number];
export type AbilityScores = Record<AbilityKey, number>;
export interface SkillDef {
key: string;
label: string;
ability: AbilityKey;
}
/** PF2e proficiency ranks; 5e collapses to trained/untrained semantics. */
export type ProficiencyRank = 'untrained' | 'trained' | 'expert' | 'master' | 'legendary';
export interface DerivedSkill {
key: string;
label: string;
ability: AbilityKey;
modifier: number;
}
/**
* The contract every supported game system implements. The UI and stores talk
* to this interface only — no `if (system === '5e')` scattered through features.
*/
export interface RulesSystem {
id: SystemId;
label: string;
abilities: readonly AbilityKey[];
abilityLabels: Record<AbilityKey, string>;
skills: readonly SkillDef[];
/** Standard d20-family ability modifier: floor((score - 10) / 2). */
abilityModifier(score: number): number;
/** Default ability scores for a new character. */
defaultAbilityScores(): AbilityScores;
/** Initiative modifier for a character. */
initiativeModifier(input: CharacterRulesInput): number;
/** Per-skill modifiers given a character's stats. */
skillModifiers(input: CharacterRulesInput): DerivedSkill[];
/** Saving-throw / defense modifiers, keyed by ability. */
saveModifiers(input: CharacterRulesInput): Record<AbilityKey, number>;
/** Suggested unarmored defense/AC. */
baseArmorClass(input: CharacterRulesInput): number;
/** Proficiency contribution (5e: bonus by level; PF2e: rank+level). */
proficiencyValue(level: number, rank: ProficiencyRank): number;
/** Spellcasting save DC for the given casting ability, if any. */
spellSaveDc?(input: CharacterRulesInput, ability: AbilityKey): number;
}
/** Minimal character shape the rules layer needs to compute derived values. */
export interface CharacterRulesInput {
level: number;
abilities: AbilityScores;
/** rank per skill key; missing = untrained */
skillRanks?: Record<string, ProficiencyRank>;
/** rank per save ability; missing = untrained */
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
/** flat AC bonus from armor/shield/etc. */
armorBonus?: number;
/** PF2e: Perception proficiency (drives initiative). */
perceptionRank?: ProficiencyRank;
/** PF2e: spellcasting proficiency. */
spellcastingRank?: ProficiencyRank;
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod';
import { systemIdSchema } from './common';
export const campaignSchema = z.object({
id: z.string(),
name: z.string().min(1, 'Name is required').max(120),
system: systemIdSchema,
description: z.string().max(5000).default(''),
/** ISO date string */
createdAt: z.string(),
updatedAt: z.string(),
});
export type Campaign = z.infer<typeof campaignSchema>;
/** Fields a user supplies when creating a campaign. */
export const campaignDraftSchema = campaignSchema.pick({
name: true,
system: true,
description: true,
});
export type CampaignDraft = z.infer<typeof campaignDraftSchema>;
+51
View File
@@ -0,0 +1,51 @@
import { z } from 'zod';
import {
abilityScoresSchema,
hpSchema,
int,
proficiencyRankSchema,
systemIdSchema,
} from './common';
export const characterKindSchema = z.enum(['pc', 'npc']);
export const characterSchema = z.object({
id: z.string(),
campaignId: z.string(),
system: systemIdSchema,
kind: characterKindSchema.default('pc'),
name: z.string().min(1).max(120),
/** ancestry (PF2e) / race (5e), free text for MVP */
ancestry: z.string().max(80).default(''),
/** class, free text for MVP */
className: z.string().max(80).default(''),
level: int.min(1).max(20).default(1),
abilities: abilityScoresSchema,
hp: hpSchema,
speed: int.nonnegative().default(30),
armorBonus: int.default(0),
skillRanks: z.record(z.string(), proficiencyRankSchema).default({}),
saveRanks: z.record(z.string(), proficiencyRankSchema).default({}),
perceptionRank: proficiencyRankSchema.default('trained'),
spellcastingRank: proficiencyRankSchema.optional(),
spellcastingAbility: z.enum(['int', 'wis', 'cha']).optional(),
notes: z.string().max(20000).default(''),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Character = z.infer<typeof characterSchema>;
export const characterDraftSchema = characterSchema.pick({
name: true,
kind: true,
ancestry: true,
className: true,
level: true,
});
export type CharacterDraft = z.infer<typeof characterDraftSchema>;
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod';
export const systemIdSchema = z.enum(['5e', 'pf2e']);
export const proficiencyRankSchema = z.enum([
'untrained',
'trained',
'expert',
'master',
'legendary',
]);
/** A finite integer — rejects NaN and Infinity, which the old app accepted everywhere. */
export const int = z.number().int().finite();
/** A finite number (allows fractions, e.g. CR 1/2). */
export const num = z.number().finite();
export const abilityScoresSchema = z.object({
str: int,
dex: int,
con: int,
int: int,
wis: int,
cha: int,
});
export const hpSchema = z.object({
current: int,
max: int.nonnegative(),
temp: int.nonnegative().default(0),
});
export const conditionSchema = z.object({
name: z.string().min(1),
/** stacking value, e.g. PF2e frightened 2 or 5e exhaustion level */
value: int.positive().optional(),
});
export type HitPoints = z.infer<typeof hpSchema>;
export type Condition = z.infer<typeof conditionSchema>;
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { num } from './common';
export const diceRollSchema = z.object({
id: z.string(),
campaignId: z.string().optional(),
label: z.string().max(120).default(''),
expression: z.string().min(1),
total: num,
breakdown: z.string(),
createdAt: z.string(),
});
export type DiceRoll = z.infer<typeof diceRollSchema>;
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
import { conditionSchema, hpSchema, int } from './common';
export const combatantKindSchema = z.enum(['pc', 'npc', 'monster']);
export const combatantSchema = z.object({
id: z.string(),
name: z.string().min(1).max(120),
kind: combatantKindSchema,
/** link to a Character, if this combatant is a tracked PC/NPC */
characterId: z.string().optional(),
/** ref into the bestiary, if spawned from a monster */
monsterRef: z.string().optional(),
initiative: int,
ac: int,
hp: hpSchema,
conditions: z.array(conditionSchema).default([]),
notes: z.string().max(2000).default(''),
});
export type Combatant = z.infer<typeof combatantSchema>;
export const encounterStatusSchema = z.enum(['planning', 'active', 'ended']);
export const encounterSchema = z.object({
id: z.string(),
campaignId: z.string(),
name: z.string().min(1).max(120),
status: encounterStatusSchema.default('planning'),
round: int.nonnegative().default(0),
/** index into combatants of whose turn it is */
turnIndex: int.nonnegative().default(0),
combatants: z.array(combatantSchema).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Encounter = z.infer<typeof encounterSchema>;
+5
View File
@@ -0,0 +1,5 @@
export * from './common';
export * from './campaign';
export * from './character';
export * from './encounter';
export * from './dice';
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useRef } from 'react';
/**
* Returns a debounced wrapper around `fn` that also flushes on unmount, so the
* last edit is never lost when navigating away (the old app silently dropped
* unsaved character changes on navigation).
*/
export function useDebouncedCallback<A extends unknown[]>(
fn: (...args: A) => void,
delay = 400,
): (...args: A) => void {
const fnRef = useRef(fn);
fnRef.current = fn;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pending = useRef<A | null>(null);
const flush = () => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (pending.current) {
fnRef.current(...pending.current);
pending.current = null;
}
};
useEffect(() => flush, []); // flush on unmount
return useMemo(
() =>
(...args: A) => {
pending.current = args;
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
timer.current = null;
if (pending.current) {
fnRef.current(...pending.current);
pending.current = null;
}
}, delay);
},
[delay],
);
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
import { useUiStore, applyTheme } from './stores/uiStore';
import './styles/globals.css';
// Apply persisted theme before first paint.
applyTheme(useUiStore.getState().theme);
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+38
View File
@@ -0,0 +1,38 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { RootLayout } from '@/app/RootLayout';
import { CampaignsPage } from '@/features/campaigns/CampaignsPage';
import { CharactersPage } from '@/features/characters/CharactersPage';
import { CharacterSheetPage } from '@/features/characters/CharacterSheetPage';
import { CombatPage } from '@/features/combat/CombatPage';
import { DicePage } from '@/features/dice/DicePage';
import { CompendiumPage } from '@/features/compendium/CompendiumPage';
const rootRoute = createRootRoute({ component: RootLayout });
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: CampaignsPage });
const charactersRoute = createRoute({ getParentRoute: () => rootRoute, path: '/characters', component: CharactersPage });
const characterSheetRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/characters/$characterId',
component: CharacterSheetPage,
});
const combatRoute = createRoute({ getParentRoute: () => rootRoute, path: '/combat', component: CombatPage });
const diceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dice', component: DicePage });
const compendiumRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compendium', component: CompendiumPage });
const routeTree = rootRoute.addChildren([
indexRoute,
charactersRoute,
characterSheetRoute,
combatRoute,
diceRoute,
compendiumRoute,
]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Theme = 'dark' | 'light';
interface UiState {
theme: Theme;
/** id of the campaign currently in focus across feature screens */
activeCampaignId: string | null;
/** encounter currently open in the combat tracker (so the bestiary can target it) */
activeEncounterId: string | null;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void;
}
export const useUiStore = create<UiState>()(
persist(
(set, get) => ({
theme: 'dark',
activeCampaignId: null,
activeEncounterId: null,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null }),
setActiveEncounter: (id) => set({ activeEncounterId: id }),
}),
{
name: 'ttrpg-ui',
onRehydrateStorage: () => (state) => {
applyTheme(state?.theme ?? 'dark');
},
},
),
);
/** Reflect the theme on <html>. The old app's light theme was a no-op; this isn't. */
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/rules/abilities.ts","./src/lib/rules/index.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
+1 -1
View File
@@ -14,5 +14,5 @@
"noUnusedParameters": true,
"types": ["node"]
},
"include": ["vite.config.ts", "scripts/**/*.ts"]
"include": ["vite.config.ts", "vitest.config.ts", "scripts/**/*.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts","./vitest.config.ts"],"version":"5.9.3"}
-8
View File
@@ -1,4 +1,3 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
@@ -56,11 +55,4 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
css: false,
exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**'],
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
css: false,
exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**'],
},
});