Perfection audit: fix 262 findings across all subsystems
Full-app audit (15 finders + adversarial verification) and fix pass: data-safety (backup/restore/cloud baseline), rules correctness for both systems (rests, crits, slots, proficiency), combat engine (HP floor, massive-damage overflow, idempotent condition ticks), no-auto-roll enforcement everywhere, realtime protocol hardening (seat deny/takeover, room end, image budgets), PWA update flow, PF2e sheet parity (Class DC, armor picker, agile/striking), and 143 new unit/e2e tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "cup26-web",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": ["-lc", "cd /home/nilsb/Documents/Projects/cup26 && exec bun run dev -- --port 5174 --strictPort"],
|
||||
"port": 5174
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@ test('generate ability scores and level up', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Characters' }).click();
|
||||
// Guided wizard builds a complete level-1 character (standard array → STR 15).
|
||||
await createCharacter(page, 'Builder');
|
||||
await expect(page.getByLabel('STR score')).toHaveValue('15');
|
||||
// Ability scores render as roll buttons ("STR +2 15") — standard array puts 15 in STR.
|
||||
await expect(page.getByRole('button', { name: 'STR +2 15' })).toBeVisible();
|
||||
|
||||
// Guided level-up from 1 -> 2 (HP + any choices applied automatically)
|
||||
await page.getByRole('button', { name: 'Level up' }).click();
|
||||
await page.getByRole('button', { name: /Apply level 2/ }).click();
|
||||
await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2');
|
||||
await page.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(page.getByRole('spinbutton', { name: 'Barbarian level' })).toHaveValue('2');
|
||||
});
|
||||
|
||||
@@ -18,8 +18,12 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
|
||||
await page.getByRole('link', { name: 'Characters' }).click();
|
||||
await createCharacter(page, 'Gandalf');
|
||||
|
||||
// Set INT high so spell DC is computable
|
||||
await page.getByLabel('INT score').fill('18');
|
||||
// Set INT high so spell DC is computable — ability edits live in the Breakdown
|
||||
// modal (standard-array INT 12 + 6 manual = 18).
|
||||
await page.getByRole('button', { name: 'Breakdown' }).click();
|
||||
await page.locator('tr', { hasText: 'Intelligence' }).getByRole('button', { name: '±' }).click();
|
||||
await page.getByLabel('Manual override for Intelligence').fill('6');
|
||||
await page.getByRole('button', { name: 'Close dialog' }).click();
|
||||
|
||||
// Inventory — add an item via Enter
|
||||
await page.getByPlaceholder('Longsword', { exact: true }).fill('Staff of Power');
|
||||
@@ -35,14 +39,15 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
|
||||
await page.getByPlaceholder('Longsword, Shortbow…').press('Enter');
|
||||
await expect(page.getByText('to hit')).toBeVisible();
|
||||
|
||||
// Resources — add one, spend it, long rest restores it
|
||||
// Resources — add one, spend it, long rest restores it. The wizard grants class
|
||||
// resources (Barbarian → Rage), so scope to the Sorcery Points row.
|
||||
await page.getByPlaceholder('Ki Points, Rage, Focus…').fill('Sorcery Points');
|
||||
await page.getByPlaceholder('Ki Points, Rage, Focus…').press('Enter');
|
||||
// Only one resource exists, so the controls are unambiguous.
|
||||
await page.getByRole('button', { name: 'Spend' }).click();
|
||||
await expect(page.getByText('0/1')).toBeVisible();
|
||||
const res = page.locator('li').filter({ has: page.getByRole('button', { name: 'Remove Sorcery Points' }) });
|
||||
await res.getByRole('button', { name: 'Spend' }).click();
|
||||
await expect(res.getByText('0/1')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Long Rest' }).click();
|
||||
await expect(page.getByText('1/1')).toBeVisible();
|
||||
await expect(res.getByText('1/1')).toBeVisible();
|
||||
|
||||
// Reload — persistence survived (autosave). Wait for the debounce + beforeunload flush.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -30,6 +30,7 @@ test('creation wizard builds a complete character (HP, skills, spells)', async (
|
||||
await page.getByRole('button', { name: 'Next' }).click(); // → Spells (Wizard is a caster)
|
||||
|
||||
await expect(page.getByPlaceholder('Search spells…')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Next' }).click(); // → Details (optional flavour)
|
||||
await page.getByRole('button', { name: 'Next' }).click(); // → Review
|
||||
|
||||
await expect(page.getByText('Max HP')).toBeVisible();
|
||||
|
||||
@@ -27,7 +27,7 @@ test('combat depth: timed condition auto-expires, log records events, roll-all w
|
||||
// Add Prone with a 1-round duration
|
||||
await row.getByLabel('Condition duration in rounds').fill('1');
|
||||
await row.getByLabel('Add condition').selectOption('Prone');
|
||||
await expect(row.getByRole('button', { name: /Prone \(1r\)/ })).toBeVisible();
|
||||
await expect(row.getByText(/Prone \(1r\)/)).toBeVisible();
|
||||
|
||||
// Roll-all initiative
|
||||
await page.getByRole('button', { name: /Roll all/ }).click();
|
||||
@@ -36,7 +36,7 @@ test('combat depth: timed condition auto-expires, log records events, roll-all w
|
||||
await page.getByRole('button', { name: 'Start combat' }).click();
|
||||
await expect(page.getByRole('button', { name: 'End' })).toBeVisible(); // combat is active
|
||||
await page.getByRole('button', { name: /Next turn/ }).click();
|
||||
await expect(row.getByRole('button', { name: /Prone/ })).toHaveCount(0);
|
||||
await expect(row.getByRole('button', { name: 'Remove Prone' })).toHaveCount(0);
|
||||
|
||||
// Combat log captured the events
|
||||
await expect(page.getByText('Combat Log')).toBeVisible();
|
||||
|
||||
+5
-4
@@ -57,11 +57,12 @@ test('combat condition picker adds preset and valued conditions as tags', async
|
||||
await row.getByLabel('Add condition').selectOption('Exhaustion');
|
||||
await row.getByLabel('Condition value').fill('3');
|
||||
await row.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(row.getByRole('button', { name: /Exhaustion 3/ })).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: 'Remove Exhaustion' })).toBeVisible();
|
||||
await expect(row.getByText('Exhaustion 3', { exact: true })).toBeVisible();
|
||||
|
||||
// Clicking a tag removes it
|
||||
await row.getByRole('button', { name: /Prone/ }).click();
|
||||
await expect(row.getByRole('button', { name: /Prone/ })).toHaveCount(0);
|
||||
// The tag's remove button removes it
|
||||
await row.getByRole('button', { name: 'Remove Prone' }).click();
|
||||
await expect(row.getByRole('button', { name: 'Remove Prone' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('character can be deleted', async ({ page }) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function createCharacter(page: Page, name: string): Promise<void> {
|
||||
if (!(await next.isDisabled())) break;
|
||||
await boxes.nth(i).check();
|
||||
}
|
||||
await next.click(); // → Details (optional flavour)
|
||||
await next.click(); // → Review
|
||||
await page.getByRole('button', { name: 'Create character' }).click();
|
||||
}
|
||||
|
||||
@@ -24,12 +24,15 @@ test('rolling a skill from the sheet shows the roll tray', async ({ page }) => {
|
||||
await expect(page.locator('text=/= \\d+/').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('advantage toggle applies to any die, not just d20', async ({ page }) => {
|
||||
test('advantage toggle applies to d20 tests only — damage dice roll untouched', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Dice' }).click();
|
||||
await page.getByRole('button', { name: 'Advantage', exact: true }).click();
|
||||
// A d20 check gains the keep-highest transform…
|
||||
await page.getByRole('button', { name: 'd20', exact: true }).click();
|
||||
await expect(page.getByText(/Rolled 2d20kh1/)).toBeVisible();
|
||||
// …but a damage die is NOT doubled (advantage is a d20-test mechanic).
|
||||
await page.getByRole('button', { name: 'd4', exact: true }).click();
|
||||
// The rolled expression became 2d4kh1 (advantage on a d4)
|
||||
await expect(page.getByText(/Rolled 2d4kh1/)).toBeVisible();
|
||||
await expect(page.getByText(/Rolled 1d4\b/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('dice page: save and use a macro', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(async () => {
|
||||
indexedDB.deleteDatabase('ttrpg-manager');
|
||||
localStorage.clear();
|
||||
});
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test('PF2e sheet: Class DC card, armor picker + defense proficiency, agile/striking attacks', async ({ page }) => {
|
||||
// PF2e campaign
|
||||
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||
await page.locator('input[data-autofocus]').fill('Verify PF2e');
|
||||
await page.getByLabel('System').selectOption('pf2e');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wizard: a level-1 Fighter (non-caster keeps the flow short)
|
||||
await page.getByRole('link', { name: 'Characters' }).click();
|
||||
await page.getByRole('button', { name: '+ New character' }).first().click();
|
||||
await page.getByLabel('Name').fill('Seelah');
|
||||
await page.getByTestId('class-card').filter({ hasText: 'Fighter' }).first().click();
|
||||
const next = page.getByRole('button', { name: 'Next' });
|
||||
await next.click(); // → Origin
|
||||
await next.click(); // → Abilities
|
||||
await next.click(); // → Skills
|
||||
const boxes = page.locator('input[type="checkbox"]');
|
||||
const count = await boxes.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!(await next.isDisabled())) break;
|
||||
await boxes.nth(i).check();
|
||||
}
|
||||
await next.click(); // → Details
|
||||
await next.click(); // → Review
|
||||
await page.getByRole('button', { name: 'Create character' }).click();
|
||||
|
||||
// Class DC card with an editable proficiency rank
|
||||
await expect(page.getByText('Class DC')).toBeVisible();
|
||||
await expect(page.getByLabel('Class DC proficiency rank')).toBeVisible();
|
||||
|
||||
// PF2e armor picker + defense proficiency; equipping real armor persists
|
||||
const armor = page.getByLabel('Equipped armor');
|
||||
await expect(armor).toBeVisible();
|
||||
await expect(page.getByLabel('Armor proficiency rank')).toBeVisible();
|
||||
await armor.selectOption('Breastplate');
|
||||
|
||||
// Attacks: agile + striking are PF2e-only controls and change the derived math
|
||||
await page.getByPlaceholder('Longsword, Shortbow…').fill('Shortsword');
|
||||
await page.getByPlaceholder('Longsword, Shortbow…').press('Enter');
|
||||
await page.getByLabel('Shortsword agile').check();
|
||||
await page.getByLabel('Shortsword striking rune').selectOption('2');
|
||||
// striking doubles the damage dice: the default 1d8 becomes 2d8 on the roll button
|
||||
await expect(page.getByRole('button', { name: /2d8/ })).toBeVisible();
|
||||
});
|
||||
+7
-3
@@ -27,9 +27,13 @@ test('full core flow: campaign → character → dice → combat → compendium'
|
||||
await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible();
|
||||
await createCharacter(page, 'Ireena');
|
||||
|
||||
// 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();
|
||||
// On the sheet: ability edits live in the Breakdown modal — a +1 manual tweak
|
||||
// takes standard-array STR 15 to 16 (+3 modifier on the roll button).
|
||||
await page.getByRole('button', { name: 'Breakdown' }).click();
|
||||
await page.locator('tr', { hasText: 'Strength' }).getByRole('button', { name: '±' }).click();
|
||||
await page.getByLabel('Manual override for Strength').fill('1');
|
||||
await page.getByRole('button', { name: 'Close dialog' }).click();
|
||||
await expect(page.getByRole('button', { name: 'STR +3 16' })).toBeVisible();
|
||||
|
||||
// --- Dice ---
|
||||
await page.getByRole('link', { name: 'Dice' }).click();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,9 @@
|
||||
import json, time, os, sys, requests
|
||||
from urllib.parse import urljoin
|
||||
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "src", "assets", "srd")
|
||||
# Output path (the app reads from src/data/srd). Override with SRD_OUT for a dry run.
|
||||
OUT_DIR = os.environ.get("SRD_OUT") or os.path.normpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "src", "data", "srd"))
|
||||
BASE_V1 = "https://api.open5e.com/v1/"
|
||||
|
||||
def fetch_paginated(endpoint, max_pages=None):
|
||||
|
||||
@@ -6,6 +6,10 @@ import { normalizeFoundryClass, type FoundryClass } from '../src/lib/ruleset/nor
|
||||
|
||||
const LIST = 'https://api.github.com/repos/foundryvtt/pf2e/contents/packs/classes?ref=master';
|
||||
|
||||
/* Foundry stores some key abilities on subclass items the class JSON can't see
|
||||
* (Psychic's is on its conscious-mind subclass), so backfill from the rules. */
|
||||
const KEY_ABILITY_FALLBACK: Record<string, string[]> = { psychic: ['cha', 'int'] };
|
||||
|
||||
async function main() {
|
||||
const res = await fetch(LIST, { headers: { 'User-Agent': 'ttrpg-manager-scraper', Accept: 'application/vnd.github+json' } });
|
||||
if (!res.ok) throw new Error(`list: ${res.status}`);
|
||||
@@ -15,7 +19,10 @@ async function main() {
|
||||
const out = [];
|
||||
for (const f of classFiles) {
|
||||
const raw = (await (await fetch(f.download_url)).json()) as FoundryClass;
|
||||
out.push(normalizeFoundryClass(f.name.replace(/\.json$/, ''), raw));
|
||||
const cls = normalizeFoundryClass(f.name.replace(/\.json$/, ''), raw);
|
||||
const fallback = KEY_ABILITY_FALLBACK[cls.slug];
|
||||
if (cls.keyAbilities.length === 0 && fallback) cls.keyAbilities = fallback;
|
||||
out.push(cls);
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('public/data/pf2e/classes.json', JSON.stringify(out));
|
||||
|
||||
+45
-20
@@ -77,35 +77,54 @@ def parse_mpmb(filepath):
|
||||
|
||||
return lists
|
||||
|
||||
def safe_extract_fields(obj_str):
|
||||
# A full JS string literal: either-quoted, escape-aware (so "Melf's Minute
|
||||
# Meteors" and strings with \" inside are captured whole, not truncated).
|
||||
JS_STR = r'(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\')'
|
||||
|
||||
def js_unescape(s):
|
||||
"""Resolve JS string escapes (\\n, \\", \\xE9, \\u2022, ...) to real characters."""
|
||||
def repl(m):
|
||||
esc = m.group(1)
|
||||
if re.fullmatch(r'u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}', esc):
|
||||
return chr(int(esc[1:], 16))
|
||||
return {'n': '\n', 't': '\t', 'r': '\r'}.get(esc, esc)
|
||||
return re.sub(r'\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)', repl, s)
|
||||
|
||||
def first_string(field, obj_str):
|
||||
"""First non-empty JS string literal assigned to `field` in the object text.
|
||||
Skipping empty matches matters: creature attacks carry `description : ""`
|
||||
which would otherwise shadow the trait description that follows."""
|
||||
for m in re.finditer(r'\b' + field + r'\s*:\s*' + JS_STR, obj_str):
|
||||
val = next(g for g in m.groups() if g is not None)
|
||||
if val:
|
||||
return js_unescape(val)
|
||||
return None
|
||||
|
||||
def safe_extract_fields(obj_str, list_name):
|
||||
"""Extract common fields from a JS object literal string."""
|
||||
fields = {}
|
||||
str_fields = ['name', 'type', 'rarity', 'description', 'prerequisite']
|
||||
if list_name == 'SpellsList':
|
||||
str_fields += ['school', 'time', 'range', 'components', 'duration', 'save']
|
||||
for field in str_fields:
|
||||
val = first_string(field, obj_str)
|
||||
if val is not None:
|
||||
fields[field] = val
|
||||
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*["\']([^"\']+)["\']',
|
||||
# --- spell fields (additive; only present on SpellsList entries) ---
|
||||
'level': r'\blevel\s*:\s*(\d+)',
|
||||
'school': r'\bschool\s*:\s*["\']([^"\']+)["\']',
|
||||
'time': r'\btime\s*:\s*["\']([^"\']+)["\']',
|
||||
'range': r'\brange\s*:\s*["\']([^"\']+)["\']',
|
||||
'components': r'\bcomponents\s*:\s*["\']([^"\']+)["\']',
|
||||
'duration': r'\bduration\s*:\s*["\']([^"\']+)["\']',
|
||||
'save': r'\bsave\s*:\s*["\']([^"\']+)["\']',
|
||||
'ritual': r'\britual\s*:\s*(true|false)',
|
||||
'classes': r'\bclasses\s*:\s*(\[[^\]]*\])',
|
||||
}
|
||||
if list_name == 'SpellsList':
|
||||
patterns.update({
|
||||
'level': r'\blevel\s*:\s*(\d+)',
|
||||
'ritual': r'\britual\s*:\s*(true|false)',
|
||||
'classes': r'\bclasses\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 == 'level':
|
||||
if field in ('weight', 'level'):
|
||||
val = int(val)
|
||||
elif field == 'ritual':
|
||||
val = (val == 'true')
|
||||
@@ -117,6 +136,12 @@ def safe_extract_fields(obj_str):
|
||||
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
|
||||
# Some entries build `description` with a JS expression (or leave it empty);
|
||||
# fall back to the verbatim rules text so no entry ships without a body.
|
||||
if not fields.get('description'):
|
||||
full = first_string('descriptionFull', obj_str)
|
||||
if full:
|
||||
fields['description'] = full
|
||||
return fields
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -130,7 +155,7 @@ if __name__ == "__main__":
|
||||
# Extract key fields for each entry
|
||||
extracted = {}
|
||||
for key, obj_str in entries.items():
|
||||
extracted[key] = safe_extract_fields(obj_str)
|
||||
extracted[key] = safe_extract_fields(obj_str, name)
|
||||
|
||||
outname = f"mpmb-{name.lower().replace('list','')}.json"
|
||||
outpath = os.path.join(OUT_DIR, outname)
|
||||
|
||||
+18
-15
@@ -46,7 +46,7 @@ export class AccountStore {
|
||||
private byId = new Map<string, User>();
|
||||
private byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private loaded = false;
|
||||
private loadPromise: Promise<void> | null = null;
|
||||
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {}
|
||||
|
||||
private usersFile() { return path.join(this.dir, 'users.json'); }
|
||||
@@ -118,21 +118,24 @@ export class AccountStore {
|
||||
try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; }
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
this.loaded = true;
|
||||
try {
|
||||
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
||||
const arr = JSON.parse(raw) as User[];
|
||||
for (const u of arr) {
|
||||
this.users.set(u.username.toLowerCase(), u);
|
||||
this.byId.set(u.id, u);
|
||||
for (const h of u.tokens) this.byToken.set(h, u);
|
||||
/** Memoized so a request that lands mid-boot AWAITS the read instead of seeing an
|
||||
* empty store (which could let register() persist a users.json missing everyone). */
|
||||
load(): Promise<void> {
|
||||
this.loadPromise ??= (async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
||||
const arr = JSON.parse(raw) as User[];
|
||||
for (const u of arr) {
|
||||
this.users.set(u.username.toLowerCase(), u);
|
||||
this.byId.set(u.id, u);
|
||||
for (const h of u.tokens) this.byToken.set(h, u);
|
||||
}
|
||||
} catch (e) {
|
||||
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
|
||||
}
|
||||
} catch (e) {
|
||||
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
|
||||
}
|
||||
})();
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private persist(): Promise<void> {
|
||||
|
||||
+13
-11
@@ -41,21 +41,23 @@ export class CloudStore {
|
||||
private characters = new Map<string, CloudCharacter>();
|
||||
private byInvite = new Map<string, string>();
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private loaded = false;
|
||||
private loadPromise: Promise<void> | null = null;
|
||||
constructor(private dir: string, private now: () => number = () => Date.now()) {}
|
||||
|
||||
private file() { return path.join(this.dir, 'cloud.json'); }
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
this.loaded = true;
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
|
||||
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
|
||||
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
|
||||
}
|
||||
/** Memoized so a request that lands mid-boot awaits the read instead of seeing an empty store. */
|
||||
load(): Promise<void> {
|
||||
this.loadPromise ??= (async () => {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
|
||||
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
|
||||
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
|
||||
}
|
||||
})();
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private persist(): Promise<void> {
|
||||
|
||||
+29
-9
@@ -35,9 +35,10 @@ const HEARTBEAT_MS = 30_000;
|
||||
|
||||
export function buildServer() {
|
||||
// trustProxy: behind Traefik the socket peer is the proxy, so without this every
|
||||
// visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so
|
||||
// req.ip is the real client. Only Traefik can reach the container, so this is safe.
|
||||
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true });
|
||||
// visitor shares one rate-limit bucket. Trust exactly ONE hop (the rightmost
|
||||
// X-Forwarded-For entry, which Traefik appends): `true` would trust the whole
|
||||
// client-supplied chain, letting a visitor spoof req.ip and dodge rate limits.
|
||||
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: 1 });
|
||||
// Tolerate a body-less request that still sets content-type: application/json
|
||||
// (browsers/fetch wrappers often do this for DELETE/POST-with-no-body). The
|
||||
// default parser 400s on an empty JSON body; every handler treats a missing
|
||||
@@ -99,18 +100,18 @@ export function buildServer() {
|
||||
app.put('/api/save', async (req, reply) => {
|
||||
const u = await accounts.userByToken(bearer(req));
|
||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
||||
const body = (req.body as { blob?: string; baseSavedAt?: number | null; force?: boolean } | undefined) ?? {};
|
||||
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
||||
// Quota: the backup blob plus the user's published characters must fit their limit.
|
||||
const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id));
|
||||
if (projected > accounts.quotaFor(u)) {
|
||||
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) });
|
||||
}
|
||||
// Optimistic concurrency: if the caller names the version it last synced and
|
||||
// the cloud has since moved on (another device pushed), refuse — no silent
|
||||
// overwrite. The client then resolves (pull, or force-push).
|
||||
// Optimistic concurrency: when a cloud copy exists, only an explicit force or a
|
||||
// matching baseline may replace it. A missing/null baseline (fresh device that
|
||||
// never synced) is a conflict, NOT consent — no silent overwrite.
|
||||
const current = await accounts.blobSavedAt(u.id);
|
||||
if (current !== null && body.baseSavedAt !== undefined && body.baseSavedAt !== null && body.baseSavedAt !== current) {
|
||||
if (current !== null && body.force !== true && body.baseSavedAt !== current) {
|
||||
return reply.code(409).send({ error: 'conflict', savedAt: current });
|
||||
}
|
||||
const savedAt = await accounts.saveBlob(u.id, body.blob);
|
||||
@@ -156,6 +157,16 @@ export function buildServer() {
|
||||
const ok = await cloud.removeMember(u.id, p.id, p.userId);
|
||||
return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
|
||||
});
|
||||
// Owner-facing unpublish: delete a shared campaign (and every character published
|
||||
// into it). Admins keep their separate /api/admin route.
|
||||
app.delete('/api/campaigns/:id', async (req, reply) => {
|
||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const id = (req.params as { id: string }).id;
|
||||
await cloud.load();
|
||||
if (!cloud.isOwner(id, u.id)) return reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can delete it.' });
|
||||
const ok = await cloud.deleteCampaign(id);
|
||||
return ok ? { ok: true } : reply.code(404).send({ error: 'not-found' });
|
||||
});
|
||||
app.put('/api/characters', async (req, reply) => {
|
||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
|
||||
@@ -176,8 +187,15 @@ export function buildServer() {
|
||||
const id = (req.params as { id: string }).id;
|
||||
await cloud.load();
|
||||
if (!cloud.isMember(id, u.id)) return reply.code(403).send({ error: 'forbidden' });
|
||||
// Full sheet data goes only to its owner and the campaign owner (the GM);
|
||||
// other members get the listing without the payload.
|
||||
const isGm = cloud.isOwner(id, u.id);
|
||||
const chars = await cloud.listCharacters(id);
|
||||
return chars.map((c) => ({ id: c.id, name: c.name, ownerUserId: c.ownerUserId, mine: c.ownerUserId === u.id, data: c.data, updatedAt: c.updatedAt }));
|
||||
return chars.map((c) => ({
|
||||
id: c.id, name: c.name, ownerUserId: c.ownerUserId, mine: c.ownerUserId === u.id,
|
||||
...(isGm || c.ownerUserId === u.id ? { data: c.data } : {}),
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
});
|
||||
app.delete('/api/characters/:id', async (req, reply) => {
|
||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||
@@ -331,12 +349,14 @@ export function buildServer() {
|
||||
case 'requestImage': hub.requestImage(sender, m.id); break;
|
||||
case 'claimSeat': hub.claimSeat(sender, m.characterId, m.offlineSnapshot); break;
|
||||
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
|
||||
case 'seatDeny': hub.seatDeny(sender, m.gmSecret, m.targetPlayerId); break;
|
||||
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
|
||||
case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
|
||||
case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
|
||||
case 'privateState': hub.privateState(sender, m.gmSecret, m.targetPlayerId, m.handout); break;
|
||||
case 'chat': hub.chat(sender, m.body, m.to); break;
|
||||
case 'setName': hub.setName(sender, m.name); break;
|
||||
case 'end': hub.end(sender, m.gmSecret); break;
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
|
||||
|
||||
@@ -192,7 +192,10 @@ describe('RoomHub', () => {
|
||||
expect(lastOf(p2, 'seatGranted')).toBeUndefined(); // seat was reclaimed by no one
|
||||
});
|
||||
|
||||
it('does not let a second live connection hijack an active seat with the same id', () => {
|
||||
it('a newer connection with the same stable id takes over the seat (fast reconnect)', () => {
|
||||
// The id is a private per-browser UUID: whoever presents it IS that browser.
|
||||
// A reconnect inside the heartbeat window (old socket not yet reaped) must
|
||||
// reclaim the seat on the NEW socket instead of being demoted to a fresh id.
|
||||
const hub = new RoomHub();
|
||||
const gm = fake(); hub.host(gm);
|
||||
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
|
||||
@@ -201,9 +204,14 @@ describe('RoomHub', () => {
|
||||
hub.claimSeat(p1, 'ch1', char);
|
||||
hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char);
|
||||
|
||||
// p1 is still live; a second socket presenting the same id must NOT inherit the seat.
|
||||
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID);
|
||||
expect(lastOf(p2, 'seatGranted')).toBeUndefined();
|
||||
expect(lastOf(p2, 'seatGranted')?.character.id).toBe(char.id); // seat follows the newest socket
|
||||
// The stale connection is evicted: its patches are no longer accepted.
|
||||
hub.playerPatch(p1, char.id, { hp: { current: 1, max: 10, temp: 0 } });
|
||||
expect(lastOf(gm, 'playerPatched')).toBeUndefined();
|
||||
// The new connection's patches ARE accepted.
|
||||
hub.playerPatch(p2, char.id, { hp: { current: 2, max: 10, temp: 0 } });
|
||||
expect(lastOf(gm, 'playerPatched')?.diff.hp?.current).toBe(2);
|
||||
});
|
||||
|
||||
it('broadcasts a roster (GM + players) to everyone', () => {
|
||||
|
||||
+89
-11
@@ -46,11 +46,14 @@ interface Room {
|
||||
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
|
||||
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
|
||||
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
|
||||
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number }
|
||||
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number; maxTotalImageBytes: number }
|
||||
const DEFAULT_LIMITS: RoomLimits = {
|
||||
maxRooms: Number(process.env.MAX_ROOMS) || 200,
|
||||
maxImagesPerRoom: 40,
|
||||
maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room
|
||||
// Global ceiling across ALL rooms — per-room caps alone could OOM a small box
|
||||
// (200 rooms × 40 MB ≫ container memory).
|
||||
maxTotalImageBytes: Number(process.env.MAX_TOTAL_IMAGE_BYTES) || 192 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function sha256(s: string): string {
|
||||
@@ -72,6 +75,8 @@ export class RoomHub {
|
||||
private byCode = new Map<string, string>();
|
||||
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
|
||||
private limits: RoomLimits;
|
||||
/** bytes of images held across ALL rooms (global memory guardrail) */
|
||||
private totalImageBytes = 0;
|
||||
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
}
|
||||
@@ -102,6 +107,13 @@ export class RoomHub {
|
||||
}
|
||||
}
|
||||
}
|
||||
// One live room per hosting socket: re-hosting replaces this socket's previous
|
||||
// room (its players are told), so a single connection can't spam the registry.
|
||||
const existing = this.conns.get(socket);
|
||||
if (existing && existing.role === 'gm') {
|
||||
const prev = this.rooms.get(existing.roomId);
|
||||
if (prev && prev.gm === socket) this.closeRoom(prev, 'The GM started a new session.');
|
||||
}
|
||||
// Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out;
|
||||
// a sweep runs first to reclaim any that just expired before we refuse.
|
||||
if (this.rooms.size >= this.limits.maxRooms) {
|
||||
@@ -133,13 +145,20 @@ export class RoomHub {
|
||||
}
|
||||
room.players.add(socket);
|
||||
room.lastActivity = this.now();
|
||||
// Reuse the client's stable id (so a transient drop keeps the seat) ONLY when it
|
||||
// isn't already live on another connection — preventing a second tab/peer from
|
||||
// seizing an active seat. The id is a client-generated UUID kept in localStorage.
|
||||
// Reuse the client's stable id so a transient drop keeps the seat. The id is a
|
||||
// client-generated UUID kept in localStorage, so a connection presenting it IS
|
||||
// that browser: if a stale socket still holds it (fast reconnect inside the
|
||||
// heartbeat window), the new connection takes over rather than being demoted
|
||||
// to a fresh id (which silently stripped the seat).
|
||||
let playerId: string = crypto.randomUUID();
|
||||
if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) {
|
||||
const liveElsewhere = [...this.conns.values()].some((c) => c.roomId === room.roomId && c.playerId === clientPlayerId);
|
||||
if (!liveElsewhere) playerId = clientPlayerId;
|
||||
playerId = clientPlayerId;
|
||||
for (const [s, c] of this.conns) {
|
||||
if (s !== socket && c.roomId === room.roomId && c.playerId === clientPlayerId) {
|
||||
room.players.delete(s);
|
||||
this.conns.delete(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId });
|
||||
socket.send({ t: 'joined', roomId: room.roomId });
|
||||
@@ -166,17 +185,23 @@ export class RoomHub {
|
||||
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
||||
const room = this.gmRoom(socket, gmSecret);
|
||||
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
||||
// Bound per-room image memory: cap the count of distinct images and the total bytes.
|
||||
// Bound image memory: cap the per-room count/bytes AND the global total across
|
||||
// all rooms (per-room caps alone could OOM the container).
|
||||
const size = Buffer.byteLength(dataUrl);
|
||||
const prev = room.images.get(id);
|
||||
const prevSize = prev ? Buffer.byteLength(prev) : 0;
|
||||
const isNew = prev === undefined;
|
||||
if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) {
|
||||
if (
|
||||
(isNew && room.images.size >= this.limits.maxImagesPerRoom)
|
||||
|| room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes
|
||||
|| this.totalImageBytes - prevSize + size > this.limits.maxTotalImageBytes
|
||||
) {
|
||||
socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' });
|
||||
return;
|
||||
}
|
||||
room.images.set(id, dataUrl);
|
||||
room.imageBytes += size - prevSize;
|
||||
this.totalImageBytes += size - prevSize;
|
||||
room.lastActivity = this.now();
|
||||
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
||||
}
|
||||
@@ -214,6 +239,18 @@ export class RoomHub {
|
||||
this.sendRoster(room);
|
||||
}
|
||||
|
||||
/** GM declines a seat request: clear it server-side (so a GM reconnect doesn't
|
||||
* resurrect it) and tell the player instead of leaving them waiting forever. */
|
||||
seatDeny(socket: Sender, gmSecret: string, targetPlayerId: string): void {
|
||||
const room = this.gmRoom(socket, gmSecret);
|
||||
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
||||
room.lastActivity = this.now();
|
||||
room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== targetPlayerId);
|
||||
for (const [s, c] of this.conns) {
|
||||
if (c.roomId === room.roomId && c.playerId === targetPlayerId) s.send({ t: 'seatDenied' });
|
||||
}
|
||||
}
|
||||
|
||||
/** GM sends a private handout/note/image to one player; non-recipients never receive it. */
|
||||
privateState(socket: Sender, gmSecret: string, targetPlayerId: string, handout: PrivateHandout | null): void {
|
||||
const room = this.gmRoom(socket, gmSecret);
|
||||
@@ -233,9 +270,15 @@ export class RoomHub {
|
||||
const conn = this.conns.get(s);
|
||||
if (!conn || seen.has(conn.playerId)) continue;
|
||||
seen.add(conn.playerId);
|
||||
const seatName = room.seats.get(conn.playerId)?.name;
|
||||
const seat = room.seats.get(conn.playerId);
|
||||
const seatName = seat?.name;
|
||||
const name = conn.name ?? seatName ?? `Player ${conn.playerId.slice(0, 4)}`;
|
||||
players.push({ playerId: conn.playerId, name, ...(conn.name && seatName ? { character: seatName } : {}) });
|
||||
players.push({
|
||||
playerId: conn.playerId,
|
||||
name,
|
||||
...(conn.name && seatName ? { character: seatName } : {}),
|
||||
...(seat ? { characterId: seat.characterId } : {}),
|
||||
});
|
||||
}
|
||||
const msg = { t: 'roster', players } as const;
|
||||
room.gm?.send(msg);
|
||||
@@ -261,6 +304,17 @@ export class RoomHub {
|
||||
const seat = room.seats.get(conn.playerId);
|
||||
if (!seat || seat.characterId !== characterId) return; // must hold the seat for this character
|
||||
room.lastActivity = this.now();
|
||||
// Keep the seat's stored sheet current, so a reconnect re-grant doesn't hand the
|
||||
// player back a stale grant-time snapshot that reverts their edits.
|
||||
seat.character = {
|
||||
...seat.character,
|
||||
...(diff.hp ? { hp: diff.hp } : {}),
|
||||
...(diff.conditions ? { conditions: diff.conditions } : {}),
|
||||
...(diff.spellcasting ? { spellcasting: diff.spellcasting } : {}),
|
||||
...(diff.resources ? { resources: diff.resources } : {}),
|
||||
...(diff.defenses ? { defenses: diff.defenses } : {}),
|
||||
...(diff.concentration !== undefined ? { concentration: diff.concentration } : {}),
|
||||
};
|
||||
room.gm?.send({ t: 'playerPatched', characterId, diff });
|
||||
}
|
||||
|
||||
@@ -328,12 +382,36 @@ export class RoomHub {
|
||||
this.conns.delete(socket);
|
||||
}
|
||||
|
||||
/** GM explicitly ends the session — players are told instead of watching a dead room. */
|
||||
end(socket: Sender, gmSecret: string): void {
|
||||
const room = this.gmRoom(socket, gmSecret);
|
||||
if (!room) return;
|
||||
this.closeRoom(room, 'The GM ended the session.');
|
||||
}
|
||||
|
||||
/** Tear a room down, informing any connected players why. */
|
||||
private closeRoom(room: Room, reason: string): void {
|
||||
for (const p of room.players) {
|
||||
p.send({ t: 'error', code: 'room-closed', message: reason });
|
||||
this.conns.delete(p);
|
||||
}
|
||||
if (room.gm) this.conns.delete(room.gm);
|
||||
this.totalImageBytes -= room.imageBytes;
|
||||
this.byCode.delete(room.joinCode);
|
||||
this.rooms.delete(room.roomId);
|
||||
}
|
||||
|
||||
/** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
const cutoff = now - ROOM_TTL_MS;
|
||||
for (const [id, room] of this.rooms) {
|
||||
if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); continue; }
|
||||
if (room.lastActivity < cutoff) {
|
||||
this.totalImageBytes -= room.imageBytes;
|
||||
this.byCode.delete(room.joinCode);
|
||||
this.rooms.delete(id);
|
||||
continue;
|
||||
}
|
||||
let dropped = false;
|
||||
for (const [pid, seat] of room.seats) {
|
||||
if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { HandoutControl } from '@/features/play/HandoutControl';
|
||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
||||
import { useCharacterPublishSync } from '@/features/cloud/useCharacterPublishSync';
|
||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||
import { SignalsBell } from '@/features/assistant/SignalsBell';
|
||||
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
||||
@@ -101,6 +102,7 @@ export function RootLayout() {
|
||||
useSessionBroadcaster(activeCampaign ?? null);
|
||||
usePlayerConnection();
|
||||
useCloudAutosave();
|
||||
useCharacterPublishSync();
|
||||
// Instance admins get an extra nav entry (server re-checks every /api/admin call).
|
||||
const isAdmin = useIsAdmin();
|
||||
const nav: NavItem[] = isAdmin
|
||||
@@ -155,7 +157,9 @@ export function RootLayout() {
|
||||
<div key={group.id}>
|
||||
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
|
||||
{nav.filter((n) => n.group === group.id).map((item) => {
|
||||
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
|
||||
// Exact-or-segment match: plain startsWith lit "Live Session" (/play)
|
||||
// on the Player setup page (/player).
|
||||
const active = item.exact ? pathname === item.to : pathname === item.to || pathname.startsWith(`${item.to}/`);
|
||||
const Ico = item.icon;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useUiStore } from '@/stores/uiStore';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
|
||||
import { useIsAdmin } from '@/features/admin/useIsAdmin';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Command {
|
||||
@@ -18,6 +19,7 @@ const NAV: { label: string; to: string }[] = [
|
||||
{ label: 'Campaigns', to: '/' },
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Assistant', to: '/assistant' },
|
||||
{ label: 'AI Director', to: '/director' },
|
||||
{ label: 'Characters', to: '/characters' },
|
||||
{ label: 'Combat', to: '/combat' },
|
||||
{ label: 'Dice', to: '/dice' },
|
||||
@@ -25,9 +27,11 @@ const NAV: { label: string; to: string }[] = [
|
||||
{ label: 'Notes', to: '/notes' },
|
||||
{ label: 'NPCs', to: '/npcs' },
|
||||
{ label: 'Quests', to: '/quests' },
|
||||
{ label: 'Calendar', to: '/calendar' },
|
||||
{ label: 'Maps', to: '/maps' },
|
||||
{ label: 'Homebrew', to: '/homebrew' },
|
||||
{ label: 'Player View', to: '/play' },
|
||||
{ label: 'Player Setup', to: '/player' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
];
|
||||
|
||||
@@ -40,10 +44,12 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
const notes = useNotes(cid);
|
||||
const npcs = useNpcs(cid);
|
||||
const quests = useQuests(cid);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [idx, setIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
@@ -51,38 +57,55 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
void navigate(params ? { to, params } : { to });
|
||||
onClose();
|
||||
};
|
||||
/** Navigate to a list page AND open the picked item (one-shot reveal). */
|
||||
const goReveal = (to: string, kind: 'note' | 'npc' | 'quest', id: string) => () => {
|
||||
useUiStore.getState().setPendingReveal({ kind, id });
|
||||
void navigate({ to });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const list: Command[] = NAV.map((n) => ({ id: `nav:${n.to}`, label: n.label, hint: 'Go', run: go(n.to) }));
|
||||
if (isAdmin) list.push({ id: 'nav:/admin', label: 'Admin', hint: 'Go', run: go('/admin') });
|
||||
list.push({ id: 'act:theme', label: 'Toggle theme', hint: 'Action', run: () => { toggleTheme(); onClose(); } });
|
||||
for (const c of characters) list.push({ id: `char:${c.id}`, label: c.name, hint: 'Character', run: go('/characters/$characterId', { characterId: c.id }) });
|
||||
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: go('/notes') });
|
||||
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: go('/npcs') });
|
||||
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: go('/quests') });
|
||||
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: goReveal('/notes', 'note', n.id) });
|
||||
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: goReveal('/npcs', 'npc', n.id) });
|
||||
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: goReveal('/quests', 'quest', qu.id) });
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [characters, notes, npcs, quests, toggleTheme]);
|
||||
}, [characters, notes, npcs, quests, toggleTheme, isAdmin]);
|
||||
|
||||
const fuse = useMemo(() => new Fuse(commands, { keys: ['label'], threshold: 0.4 }), [commands]);
|
||||
const results = useMemo(() => (q.trim() ? fuse.search(q.trim()).map((r) => r.item) : commands).slice(0, 50), [q, fuse, commands]);
|
||||
const clampedIdx = Math.min(idx, Math.max(0, results.length - 1));
|
||||
|
||||
// Bound on the dialog wrapper (not just the input) so Escape closes and arrows
|
||||
// work even after focus moved to a result button. Tab is trapped inside the
|
||||
// aria-modal dialog, mirroring Modal.tsx — it must not walk into the page behind.
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(results.length - 1, i + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(0, i - 1)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); results[clampedIdx]?.run(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
else if (e.key === 'Tab') {
|
||||
const focusable = panelRef.current?.querySelectorAll<HTMLElement>('input, button:not([disabled])');
|
||||
if (!focusable || 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(); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-24" role="dialog" aria-modal aria-label="Command palette">
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-24" role="dialog" aria-modal aria-label="Command palette" onKeyDown={onKeyDown}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-hidden />
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-2xl">
|
||||
<div ref={panelRef} className="relative w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-2xl">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setIdx(0); }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search pages, characters, notes…"
|
||||
aria-label="Command search"
|
||||
className="w-full border-b border-line bg-transparent px-4 py-3 text-sm text-ink placeholder:text-muted focus:outline-none"
|
||||
@@ -95,6 +118,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
<li key={c.id}>
|
||||
<button
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
onFocus={() => setIdx(i)}
|
||||
onClick={() => c.run()}
|
||||
className={cn('flex w-full items-center justify-between px-4 py-2 text-left text-sm', i === clampedIdx ? 'bg-elevated text-ink' : 'text-muted')}
|
||||
>
|
||||
|
||||
@@ -4,10 +4,16 @@ import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
||||
import { naturalD20 } from '@/lib/dice/notation';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** A nat-20 / critical-success is a crit; a nat-1 / critical-failure is a fumble. */
|
||||
/** A critical-success is a crit; a critical-failure is a fumble. When a degree
|
||||
* was computed against a DC it is authoritative — a natural 20 that only
|
||||
* achieved a plain Success (PF2e step rules) must not shout "Critical". The
|
||||
* nat-20/nat-1 flair applies only to raw rolls with no DC. */
|
||||
function critKind(roll: TrayRoll): 'crit' | 'fumble' | null {
|
||||
if (roll.degree === 'critical-success') return 'crit';
|
||||
if (roll.degree === 'critical-failure') return 'fumble';
|
||||
if (roll.degree) {
|
||||
if (roll.degree === 'critical-success') return 'crit';
|
||||
if (roll.degree === 'critical-failure') return 'fumble';
|
||||
return null;
|
||||
}
|
||||
const n = naturalD20(roll.result);
|
||||
if (n === 20) return 'crit';
|
||||
if (n === 1) return 'fumble';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -588,7 +588,7 @@
|
||||
]
|
||||
},
|
||||
"celebrity adventurer's scion": {
|
||||
"name": "Celebrity Adventurer",
|
||||
"name": "Celebrity Adventurer's Scion",
|
||||
"source": [
|
||||
{
|
||||
"source": "AcqInc",
|
||||
|
||||
@@ -677,7 +677,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "I can communicate with humanoids who don't speak any language I know. I must observe the humanoids interacting with one another for at least one day, after which I learn a handful of important words, expressions, and gestures \\u2015 enough to communicate on a rudimentary level."
|
||||
"description": "I can communicate with humanoids who don't speak any language I know. I must observe the humanoids interacting with one another for at least one day, after which I learn a handful of important words, expressions, and gestures \u2015 enough to communicate on a rudimentary level."
|
||||
},
|
||||
"historical knowledge": {
|
||||
"source": [
|
||||
@@ -819,7 +819,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "Provided I have carpenter's tools and wood, I can perform repairs on a water vehicle. When I use this ability, I restore a number of hit points to the hull of a water vehicle equal to 5\\xD7 my proficiency modifier. A vehicle cannot be patched by me in this way again until after it has been pulled ashore and fully repaired."
|
||||
"description": "Provided I have carpenter's tools and wood, I can perform repairs on a water vehicle. When I use this ability, I restore a number of hit points to the hull of a water vehicle equal to 5\u00d7 my proficiency modifier. A vehicle cannot be patched by me in this way again until after it has been pulled ashore and fully repaired."
|
||||
},
|
||||
"down low": {
|
||||
"source": [
|
||||
@@ -868,7 +868,7 @@
|
||||
"page": 50
|
||||
}
|
||||
],
|
||||
"description": "My experience with the local legal system has given me a firm knowledge of its ins and outs. Even when the law is not on my side, I can use complex terms like \\"
|
||||
"description": "My experience with the local legal system has given me a firm knowledge of its ins and outs. Even when the law is not on my side, I can use complex terms like \"ex injuria jus non oritur\" to frighten people into thinking I know what I'm talking about. I might be able to intimidate or deceive folks who don't know any better to get favors or special treatment."
|
||||
},
|
||||
"inside informant": {
|
||||
"source": [
|
||||
@@ -929,7 +929,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "I know that most of the real business, in entertainment or otherwise, happens behind the scenes. It's easy for me to case what sorts of audiences attend a venue. After a successful performance, I may meet an enthusiastic member of the crowd\\u2014someone of an occupation or social class that frequents the venue, who is delighted to talk with me, and to listen."
|
||||
"description": "I know that most of the real business, in entertainment or otherwise, happens behind the scenes. It's easy for me to case what sorts of audiences attend a venue. After a successful performance, I may meet an enthusiastic member of the crowd\u2014someone of an occupation or social class that frequents the venue, who is delighted to talk with me, and to listen."
|
||||
},
|
||||
"dual personalities": {
|
||||
"source": [
|
||||
@@ -981,7 +981,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "I know the city that most of its inhabitants ignore, the dog-eat-dog world of the homeless and unfortunate. I know where to go for anonymity. In these slums and alley camps, I can get a damp bed and a bad meal, but also a degree of privacy and no questions asked. Living here isn't comfortable, but it's unlikely any will find me\\u2014and I can stay as long as I want."
|
||||
"description": "I know the city that most of its inhabitants ignore, the dog-eat-dog world of the homeless and unfortunate. I know where to go for anonymity. In these slums and alley camps, I can get a damp bed and a bad meal, but also a degree of privacy and no questions asked. Living here isn't comfortable, but it's unlikely any will find me\u2014and I can stay as long as I want."
|
||||
},
|
||||
"patriar": {
|
||||
"source": [
|
||||
@@ -1007,7 +1007,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "Even after my short time in the city, I've learned it holds more walls and gates than those the guards patrol. I'm known within the city's immigrant communities. Should I ever need to learn about a foreign land, people, tradition, or history, I know where to find someone with firsthand experience\\u2014likely somewhere in the poorer part of town."
|
||||
"description": "Even after my short time in the city, I've learned it holds more walls and gates than those the guards patrol. I'm known within the city's immigrant communities. Should I ever need to learn about a foreign land, people, tradition, or history, I know where to find someone with firsthand experience\u2014likely somewhere in the poorer part of town."
|
||||
},
|
||||
"rumor monger": {
|
||||
"source": [
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "Via my personal rumor mill and published articles, I can surmise a great deal about the secrets of the city's inhabitants\\u2014necromancy, spying, smuggling, dealing in magical wares. Whenever a noteworthy crime or happening occurs in the city, I immediately have a list of 1d4 suspects who, if they aren't involved, have a strong chance of knowing who is."
|
||||
"description": "Via my personal rumor mill and published articles, I can surmise a great deal about the secrets of the city's inhabitants\u2014necromancy, spying, smuggling, dealing in magical wares. Whenever a noteworthy crime or happening occurs in the city, I immediately have a list of 1d4 suspects who, if they aren't involved, have a strong chance of knowing who is."
|
||||
},
|
||||
"smuggler's sense": {
|
||||
"source": [
|
||||
@@ -1085,7 +1085,7 @@
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"description": "I've associated with enough of the Gateguides crew that I know their torch-based code. From the lighting, placement, and type of torch arranged on or near a structure, I can gather information about those who live or do business there\\u2014if they deal fairly with strangers, have guild or government connections, or their standing with the Gateguides."
|
||||
"description": "I've associated with enough of the Gateguides crew that I know their torch-based code. From the lighting, placement, and type of torch arranged on or near a structure, I can gather information about those who live or do business there\u2014if they deal fairly with strangers, have guild or government connections, or their standing with the Gateguides."
|
||||
},
|
||||
"house connections": {
|
||||
"source": [
|
||||
@@ -1339,7 +1339,7 @@
|
||||
"page": 4
|
||||
}
|
||||
],
|
||||
"description": "I grew up among giants or where they lived. Something about this environment\\u2014the food, water, elemental magic, or some blessing\\u2014caused me to grow to a remarkable size for my kind. I'm used to moving through a world much bigger than I, and that is reflected in my skills, attitude, and perspective on life. I gain the Strike of the Giants feat."
|
||||
"description": "I grew up among giants or where they lived. Something about this environment\u2014the food, water, elemental magic, or some blessing\u2014caused me to grow to a remarkable size for my kind. I'm used to moving through a world much bigger than I, and that is reflected in my skills, attitude, and perspective on life. I gain the Strike of the Giants feat."
|
||||
},
|
||||
"rune shaper": {
|
||||
"source": [
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
"description": "If used after moving 20 ft straight in the same round, deals extra 2d6 damage (Charge)"
|
||||
},
|
||||
"deep rothe": {
|
||||
"name": "Deep Roth\\xE9",
|
||||
"name": "Deep Roth\u00e9",
|
||||
"type": "Beast",
|
||||
"source": [
|
||||
{
|
||||
@@ -252,7 +252,7 @@
|
||||
"description": "If used after moving 20 ft straight in the same round, deals extra 2d6 damage (Charge)"
|
||||
},
|
||||
"rothe": {
|
||||
"name": "Roth\\xE9",
|
||||
"name": "Roth\u00e9",
|
||||
"type": "Beast",
|
||||
"source": [
|
||||
{
|
||||
@@ -494,7 +494,7 @@
|
||||
"page": 236
|
||||
}
|
||||
],
|
||||
"description": "1 bite \\u0026 1 tail attack as Attack action; Target grappled \\u0026 restrained (escape DC 15); Can't use bite until grapple ends"
|
||||
"description": "1 bite & 1 tail attack as Attack action; Target grappled & restrained (escape DC 15); Can't use bite until grapple ends"
|
||||
},
|
||||
"almiraj": {
|
||||
"name": "Almiraj",
|
||||
@@ -614,7 +614,7 @@
|
||||
"page": 289
|
||||
}
|
||||
],
|
||||
"description": "1 bite \\u0026 1 claws attack as Attack action"
|
||||
"description": "1 bite & 1 claws attack as Attack action"
|
||||
},
|
||||
"fastieth": {
|
||||
"name": "Fastieth",
|
||||
@@ -768,7 +768,7 @@
|
||||
"page": 309
|
||||
}
|
||||
],
|
||||
"description": "1 bite \\u0026 1 tail attack as Attack action; See Swallow feature"
|
||||
"description": "1 bite & 1 tail attack as Attack action; See Swallow feature"
|
||||
},
|
||||
"walrus": {
|
||||
"name": "Walrus",
|
||||
@@ -823,7 +823,7 @@
|
||||
"page": 301
|
||||
}
|
||||
],
|
||||
"description": "One creature in sight; Wis save: success\\u2015 no damage"
|
||||
"description": "One creature in sight; Wis save: success\u2015 no damage"
|
||||
},
|
||||
"giant swan": {
|
||||
"name": "Giant Swan",
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"page": 167
|
||||
}
|
||||
],
|
||||
"prerequisite": "Charisma 13 or higher"
|
||||
"prerequisite": "Charisma 13 or higher",
|
||||
"description": "You can spend 10 minutes inspiring your companions, shoring up their resolve to fight. When you do so, choose up to six friendly creatures (which can include yourself) within 30 feet of you who can see or hear you and who can understand you. Each creature can gain temporary hit points equal to your level + your Charisma modifier. A creature can't gain temporary hit points from this feat again until it has finished a short or long rest."
|
||||
},
|
||||
"keen mind": {
|
||||
"name": "Keen Mind",
|
||||
@@ -181,7 +182,8 @@
|
||||
"source": "P",
|
||||
"page": 167
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "You have studied languages and codes, gaining the following benefits:\n \u2022 Increase your Intelligence score by 1, to a maximum of 20.\n \u2022 You learn three languages of your choice.\n \u2022 You can ably create written ciphers. Others can't decipher a code you create unless you teach them, they succeed on an Intelligence check (DC equal to your Intelligence score + your proficiency bonus), or they use magic to decipher it."
|
||||
},
|
||||
"lucky": {
|
||||
"name": "Lucky",
|
||||
@@ -220,7 +222,8 @@
|
||||
"source": "P",
|
||||
"page": 168
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "You have martial training that allows you to perform special combat maneuvers. You gain the following benefits:\n \u2022 You learn two maneuvers of your choice from among those available to the Battle Master archetype in the fighter class. If a maneuver you use requires your target to make a saving throw to resist the maneuver's effects, the saving throw DC equals 8 + your proficiency bonus + your Strength or Dexterity modifier (your choice).\n \u2022 You gain one superiority die, which is a d6 (this die is added to any superiority dice you have from another source). This die is used to fuel your maneuvers. A superiority die is expended when you use it. You regain your expended superiority dice when you finish a short or long rest."
|
||||
},
|
||||
"medium armor master": {
|
||||
"name": "Medium Armor Master",
|
||||
@@ -418,7 +421,7 @@
|
||||
"page": 170
|
||||
}
|
||||
],
|
||||
"description": "I gain proficiency with four simple or martial weapons of my choice.\\n[+1 Strength or Dexterity]"
|
||||
"description": "I gain proficiency with four simple or martial weapons of my choice.\n[+1 Strength or Dexterity]"
|
||||
},
|
||||
"svirfneblin magic": {
|
||||
"name": "Svirfneblin Magic",
|
||||
@@ -458,7 +461,8 @@
|
||||
"page": 74
|
||||
}
|
||||
],
|
||||
"prerequisite": "Being a Dragonborn"
|
||||
"prerequisite": "Being a Dragonborn",
|
||||
"description": "When angered, you radiate menace. You gain the following benefits:\n \u2022 Increase your Strength, Constitution, or Charisma score by 1, to a maximum of 20.\n \u2022 Instead of exhaling destructive energy, you can expend a use of your Breath Weapon trait to roar, forcing each creature of your choice within 30 feet of you to make a Wisdom saving throw (DC 8 + your proficiency bonus + your Charisma modifier). A target automatically succeeds on the save if it can't hear or see you. On a failed save, a target becomes frightened of you for 1 minute. If the frightened target takes any damage, it can repeat the saving throw, ending the effect on itself on a success."
|
||||
},
|
||||
"dragon hide": {
|
||||
"name": "Dragon Hide",
|
||||
@@ -545,7 +549,7 @@
|
||||
"page": 75
|
||||
}
|
||||
],
|
||||
"description": "I have resistance to cold and poison damage and I have advantage on saving throws against being poisoned.\\n[+1 Constitution]",
|
||||
"description": "I have resistance to cold and poison damage and I have advantage on saving throws against being poisoned.\n[+1 Constitution]",
|
||||
"prerequisite": "Being a Tiefling"
|
||||
},
|
||||
"orcish fury": {
|
||||
@@ -636,7 +640,8 @@
|
||||
"source": "UA:F2",
|
||||
"page": 1
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "You've learned some of an artificer's inventiveness:\n \u2022 You learn one cantrip of your choice from the artificer spell list, and you learn one 1st-level spell of your choice from that list. Intelligence is your spellcasting ability for these spells.\n \u2022 You can cast this feat's 1st-level spell without a spell slot, and you must finish a long rest before you can cast it in this way again. You can also cast the spell using any spell slots you have.\n \u2022 You gain proficiency with one type of artisan's tools of your choice, and you can use that type of tool as a spellcasting focus for any spell you cast that uses Intelligence as its spellcasting ability."
|
||||
},
|
||||
"chef": {
|
||||
"name": "Chef",
|
||||
@@ -674,7 +679,7 @@
|
||||
"page": 1
|
||||
}
|
||||
],
|
||||
"description": "I learn one Eldritch Invocation from the warlock class for which I meet the prerequisites (2nd page ",
|
||||
"description": "I learn one Eldritch Invocation from the warlock class for which I meet the prerequisites (2nd page \"Choose Feature\" button). I can replace this invocation for another whenever I gain a level.",
|
||||
"prerequisite": "Spellcasting or Pact Magic feature"
|
||||
},
|
||||
"fey touched": {
|
||||
@@ -732,7 +737,7 @@
|
||||
"page": 2
|
||||
}
|
||||
],
|
||||
"description": "I learn two Metamagic options from the sorcerer class (2nd page ",
|
||||
"description": "I learn two Metamagic options from the sorcerer class (2nd page \"Choose Feature\" button). I can use only one option on a spell unless it says otherwise. I gain 2 sorcery points, which I can only use for Metamagic. I regain all expended sorcery points when I finish a long rest. I can change one ",
|
||||
"prerequisite": "Spellcasting or Pact Magic feature"
|
||||
},
|
||||
"piercer": {
|
||||
@@ -1170,7 +1175,7 @@
|
||||
"page": 49
|
||||
}
|
||||
],
|
||||
"description": "I can use a card deck as a spellcasting focus. I learn and can do stage magic with Prestidigitation. I conceal its components as card tricks when doing so. When I finish a long rest, I can store a spell from my class\\' spell list into a card, see ",
|
||||
"description": "I can use a card deck as a spellcasting focus. I learn and can do stage magic with Prestidigitation. I conceal its components as card tricks when doing so. When I finish a long rest, I can store a spell from my class' spell list into a card, see \"Hidden Ace\" notes.",
|
||||
"prerequisite": "4th-level, Spellcasting feature"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"rain catcher": {
|
||||
"name": "Rain catcher [1 gp]",
|
||||
"name": "Rain catcher",
|
||||
"source": [
|
||||
{
|
||||
"source": "ToA",
|
||||
@@ -10,7 +10,7 @@
|
||||
"weight": 5
|
||||
},
|
||||
"insect repellent salve": {
|
||||
"name": "Salve (vial) [5 sp]",
|
||||
"name": "Insect Repellent Salve, applications of",
|
||||
"type": "insect repellent",
|
||||
"source": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
]
|
||||
},
|
||||
"insect repellent incense": {
|
||||
"name": "Incense (block) [1 gp]",
|
||||
"name": "Insect Repellent Incense, blocks of",
|
||||
"type": "insect repellent",
|
||||
"source": [
|
||||
{
|
||||
@@ -30,7 +30,7 @@
|
||||
]
|
||||
},
|
||||
"cold weather": {
|
||||
"name": "Cold Weather [10 gp]",
|
||||
"name": "Cold weather clothes",
|
||||
"type": "clothes",
|
||||
"source": [
|
||||
{
|
||||
@@ -41,7 +41,7 @@
|
||||
"weight": 5
|
||||
},
|
||||
"crampons (2)": {
|
||||
"name": "Crampons (2) [2 gp]",
|
||||
"name": "Crampons",
|
||||
"source": [
|
||||
{
|
||||
"source": "RotF",
|
||||
@@ -51,7 +51,7 @@
|
||||
"weight": 0
|
||||
},
|
||||
"snowshoes": {
|
||||
"name": "Snowshoes [2 gp]",
|
||||
"name": "Snowshoes",
|
||||
"source": [
|
||||
{
|
||||
"source": "RotF",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"page": 54
|
||||
}
|
||||
],
|
||||
"description": "Dwarvish runes on the head of this rusty battleaxe read ",
|
||||
"description": "Dwarvish runes on the head of this rusty battleaxe read \"Hew\". It adds a +1 bonus to attack and damage rolls made with it and deals maximum damage against plant creatures or objects made of wood. While carrying it, I feel uneasy when I travel through a forest, as its creator was a dwarf smith who feuded with dryads.",
|
||||
"weight": 4
|
||||
},
|
||||
"lightbringer": {
|
||||
@@ -92,7 +92,7 @@
|
||||
"page": 69
|
||||
}
|
||||
],
|
||||
"description": "This rusty spear is engraved with draconic runes on its crossguard which read \\",
|
||||
"description": "This rusty spear is engraved with draconic runes on its crossguard which read \"Tiamat's Eyes Shine\". The spear has 10 charges. As an action while holding it, I can say this command and expend 1 charge to cast Daylight. The spear loses its magic once all charges are expended.",
|
||||
"weight": 3
|
||||
},
|
||||
"tankard of plenty": {
|
||||
@@ -105,7 +105,7 @@
|
||||
"page": 74
|
||||
}
|
||||
],
|
||||
"description": "This golden stein is decorated with dancing dwarves and grain patterns. Speaking the command word "
|
||||
"description": "This golden stein is decorated with dancing dwarves and grain patterns. Speaking the command word \"Illefarn\" while grasping the handle fills the tankard with three pints of rich dwarven ale. This power can be used up to three times per day."
|
||||
},
|
||||
"dragon mask": {
|
||||
"name": "Dragon Mask",
|
||||
@@ -290,7 +290,7 @@
|
||||
"prerequisite": "Requires attunement by a bard"
|
||||
},
|
||||
"mariner's armor": {
|
||||
"name": "Mariner",
|
||||
"name": "Mariner's Armor",
|
||||
"type": "armor (light, medium, or heavy)",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -716,7 +716,7 @@
|
||||
"page": 157
|
||||
}
|
||||
],
|
||||
"description": "I have a +1 bonus to attack and damage rolls made with this dagger. It doesn't make noise when it hits or cuts something. If I speaks the name \\",
|
||||
"description": "I have a +1 bonus to attack and damage rolls made with this dagger. It doesn't make noise when it hits or cuts something. If I speaks the name \"Reszur\", which is engraved on its pommel, the blade gives off a faint, cold glow, shedding dim light in a 10-foot radius until I speak the name again.",
|
||||
"weight": 1
|
||||
},
|
||||
"seeker dart": {
|
||||
@@ -729,7 +729,7 @@
|
||||
"page": 223
|
||||
}
|
||||
],
|
||||
"description": "Once as an action, when I whisper \\",
|
||||
"description": "Once as an action, when I whisper \"seek\" and hurl this dart, it seeks out a target of my choice within 120 ft that I have seen at least once. If the target isn't within range or there is no clear path to it, the dart's magic is spent. Else, the target must make a DC 16 Dex save or take 1d4 piercing and 3d4 lightning damage.",
|
||||
"weight": 0
|
||||
},
|
||||
"storm boomerang": {
|
||||
@@ -965,7 +965,7 @@
|
||||
"prerequisite": "Requires attunement by a creature of lawful good alignment"
|
||||
},
|
||||
"saint markovia's thighbone": {
|
||||
"name": "Saint Markovia",
|
||||
"name": "Saint Markovia's Thighbone",
|
||||
"type": "weapon (mace)",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -1088,7 +1088,7 @@
|
||||
"description": "This wooden gavel imposes disadv. on attacks against me before my first turn in a combat. As an action once per long rest, I can strike it on a hard surface and have the first creature that deals damage with an attack within 60 ft of the strike take half that damage itself as psychic damage. Can transfer rune, see book."
|
||||
},
|
||||
"gurt's greataxe": {
|
||||
"name": "Gurt",
|
||||
"name": "Gurt's Greataxe",
|
||||
"type": "weapon (greataxe)",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -1097,7 +1097,7 @@
|
||||
"page": 234
|
||||
}
|
||||
],
|
||||
"description": "This giant-sized greataxe adds +1 to hit and damage and deals 3d12 slashing damage (+2d12 vs. humans). When in an area that is below 0 \\u00B0F, it sheds bright light in a 20-ft radius and dim light for another 20 ft. As an action once per dawn, I can use it to cast Heat Metal (DC 13) that deals cold damage instead of fire.",
|
||||
"description": "This giant-sized greataxe adds +1 to hit and damage and deals 3d12 slashing damage (+2d12 vs. humans). When in an area that is below 0 \u00b0F, it sheds bright light in a 20-ft radius and dim light for another 20 ft. As an action once per dawn, I can use it to cast Heat Metal (DC 13) that deals cold damage instead of fire.",
|
||||
"weight": 325
|
||||
},
|
||||
"ingot of the skold rune": {
|
||||
@@ -1236,7 +1236,7 @@
|
||||
"description": "Warm orange light spills from minuscule cracks that form on this rings outer surface. It automatically resizes to fit the creature attuned to it. It has 6 charges which can be used to cast Conjure Minor Elementals (summoning 4 magma mephits or 4 magmins) or Fire Shield (warm shield version only), costing 1 charge each."
|
||||
},
|
||||
"red dragon's thighbone": {
|
||||
"name": "Red Dragon",
|
||||
"name": "Red Dragon's Thighbone",
|
||||
"type": "weapon (greatclub)",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"page": 178
|
||||
}
|
||||
],
|
||||
"description": "As a bonus action, I can speak the command word to cause flames that add +2d6 fire damage and shine bright light for 40 ft \\u0026 dim light for 40 ft. The flames last until I speak the word again or sheathe it. As an action, I can mentally command it to detect type and quantity of gems and jewels within 60 ft of the sword."
|
||||
"description": "As a bonus action, I can speak the command word to cause flames that add +2d6 fire damage and shine bright light for 40 ft & dim light for 40 ft. The flames last until I speak the word again or sheathe it. As an action, I can mentally command it to detect type and quantity of gems and jewels within 60 ft of the sword."
|
||||
},
|
||||
"amulet of protection from turning": {
|
||||
"name": "Amulet of Protection from Turning",
|
||||
@@ -1785,7 +1785,7 @@
|
||||
"description": "As an action, I can doff this armor."
|
||||
},
|
||||
"charlatan's die": {
|
||||
"name": "Charlatan",
|
||||
"name": "Charlatan's Die",
|
||||
"type": "wondrous item",
|
||||
"rarity": "common",
|
||||
"source": [
|
||||
@@ -1947,7 +1947,7 @@
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
"heward's handy spice pouch": {
|
||||
"name": "Heward",
|
||||
"name": "Heward's Handy Spice Pouch",
|
||||
"type": "wondrous item",
|
||||
"rarity": "common",
|
||||
"source": [
|
||||
@@ -2273,7 +2273,7 @@
|
||||
"weight": 0
|
||||
},
|
||||
"veteran's cane": {
|
||||
"name": "Veteran",
|
||||
"name": "Veteran's Cane",
|
||||
"type": "wondrous item",
|
||||
"rarity": "common",
|
||||
"source": [
|
||||
@@ -2466,7 +2466,7 @@
|
||||
"description": "As an action once per 7 days, I can speak the command word and throw this feather to an empty large space in 5 ft, where it becomes a diatryma (axe beak stats) for 6 hours, until I speak the command again, or it reaches 0 HP. It is friendly, understands my languages, obeys my commands, and can be used as a mount."
|
||||
},
|
||||
"knave's eye patch": {
|
||||
"name": "Knave",
|
||||
"name": "Knave's Eye Patch",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -2478,7 +2478,7 @@
|
||||
"description": "While wearing this eye patch I have advantage on Perception checks that rely on sight, I am unaffected by Sunlight Sensitivity if I have it, and I am immune to magic that would read my thoughts or determine whether I'm lying. Creatures can communicate telepathically with me only if I allow it."
|
||||
},
|
||||
"lord's ensemble": {
|
||||
"name": "Lord",
|
||||
"name": "Lord's Ensemble",
|
||||
"type": "wondrous item",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -2540,7 +2540,7 @@
|
||||
"description": "I'm unwilling to part with this magic blade. When I attack a creature with it and roll a 20 to hit, it must make a DC 15 Con save or be restrained, and on a roll of 1, I must make that save. At the end of each of the target's turns, it can save again, ending the effect with 3 successes, or petrified for 1 hour after 3 failures."
|
||||
},
|
||||
"galder's bubble pipe": {
|
||||
"name": "Galder",
|
||||
"name": "Galder's Bubble Pipe",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -2552,7 +2552,7 @@
|
||||
"description": "This finely carved pipe has 3 charges, regaining all at dawn, which I can use to cast spells. As an action, I can expend all 3 charges to summon a steam mephit. It acts on its own initiative, is friendly to me, obeys my verbal commands, and disappears after 1 minute or if it ends its turn more than 60 ft from the pipe."
|
||||
},
|
||||
"heward's hireling armor": {
|
||||
"name": "Heward",
|
||||
"name": "Heward's Hireling Armor",
|
||||
"type": "armor (leather)",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -2628,7 +2628,7 @@
|
||||
"description": "This signet ring bears a symbol of its associated guild. It has 3 charges, and it regains 1d3 expended charges daily at dawn. While wearing it, I can expend 1 charge to cast the spell within (save DC 13). Aside from its magical properties, the ring is also an indicator of the guild's recognition and favor."
|
||||
},
|
||||
"illusionist's bracers": {
|
||||
"name": "Illusionist",
|
||||
"name": "Illusionist's Bracers",
|
||||
"type": "wondrous item",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -2694,7 +2694,7 @@
|
||||
"weight": 1
|
||||
},
|
||||
"pariah's shield": {
|
||||
"name": "Pariah",
|
||||
"name": "Pariah's Shield",
|
||||
"type": "shield",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -2758,7 +2758,7 @@
|
||||
"weight": 4
|
||||
},
|
||||
"spies' murmur": {
|
||||
"name": "Spies",
|
||||
"name": "Spies' Murmur",
|
||||
"type": "wondrous item",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -2912,7 +2912,7 @@
|
||||
"page": 297
|
||||
}
|
||||
],
|
||||
"description": "This ornate chair can be placed on a ship weighing up to 100 tons. It generates artificial gravity and an envelope of fresh air at 70 \\u00B0F around the ship when in the void of space. While attuned to it and sitting in the chair, I can propel the ship, steer it, and see what is happening anywhere on it, but I can't use spell slots."
|
||||
"description": "This ornate chair can be placed on a ship weighing up to 100 tons. It generates artificial gravity and an envelope of fresh air at 70 \u00b0F around the ship when in the void of space. While attuned to it and sitting in the chair, I can propel the ship, steer it, and see what is happening anywhere on it, but I can't use spell slots."
|
||||
},
|
||||
"shield of the uven rune": {
|
||||
"name": "Shield of the Uven Rune",
|
||||
@@ -3215,7 +3215,7 @@
|
||||
"page": 225
|
||||
}
|
||||
],
|
||||
"description": "This shield grants me +2 bonus to AC and resistance to fire damage. It has 3 charges, regaining all at dawn. I can expend 1 charge to cast Fireball or 2 charges to cast Wall of Fire from it at DC 21. The shield is sentient and can communicate telepathically with any creature within 120 ft of it. See ",
|
||||
"description": "This shield grants me +2 bonus to AC and resistance to fire damage. It has 3 charges, regaining all at dawn. I can expend 1 charge to cast Fireball or 2 charges to cast Wall of Fire from it at DC 21. The shield is sentient and can communicate telepathically with any creature within 120 ft of it. See \"Notes\" page for more.",
|
||||
"weight": 6
|
||||
},
|
||||
"soul coin": {
|
||||
@@ -3232,7 +3232,7 @@
|
||||
"page": 269
|
||||
}
|
||||
],
|
||||
"description": "Each coin traps a unique soul, whose rage or despair is felt by me while I hold it. A coin has 3 charges. As an action, I can expend 1 charge to either siphon the soul's essence to grant me 1d10 temporary HP or telepathically ask the soul a question which it must answer truthfully. See \\",
|
||||
"description": "Each coin traps a unique soul, whose rage or despair is felt by me while I hold it. A coin has 3 charges. As an action, I can expend 1 charge to either siphon the soul's essence to grant me 1d10 temporary HP or telepathically ask the soul a question which it must answer truthfully. See \"Notes\" page for more.",
|
||||
"weight": 0
|
||||
},
|
||||
"boots of the winding path": {
|
||||
@@ -3375,7 +3375,7 @@
|
||||
"prerequisite": "Requires attunement by a warforged"
|
||||
},
|
||||
"belashyrra's beholder crown": {
|
||||
"name": "Belashyrra",
|
||||
"name": "Belashyrra's Beholder Crown",
|
||||
"type": "wondrous item",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -3421,7 +3421,7 @@
|
||||
"prerequisite": "Requires attunement by a warforged"
|
||||
},
|
||||
"dyrrn's tentacle whip": {
|
||||
"name": "Dyrrn",
|
||||
"name": "Dyrrn's Tentacle Whip",
|
||||
"type": "weapon (whip)",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -3479,7 +3479,7 @@
|
||||
"description": "This small metal disk is inscribed with the image of a feather. When I fall at least 20 ft while the token is on my person, I descend 60 ft per round and take no damage from falling. The token's magic is expended after landing, whereupon the disk becomes nonmagical."
|
||||
},
|
||||
"finder's goggles": {
|
||||
"name": "Finder",
|
||||
"name": "Finder's Goggles",
|
||||
"type": "wondrous item",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -3538,7 +3538,7 @@
|
||||
"prerequisite": "Requires attunement by a creature with the Dragonmark of Warding"
|
||||
},
|
||||
"kyrzin's ooze": {
|
||||
"name": "Kyrzin",
|
||||
"name": "Kyrzin's Ooze",
|
||||
"type": "wondrous item",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -3615,7 +3615,7 @@
|
||||
"description": "This artificial limb replaces a lost limb, like a hand, an arm, a foot, a leg, or a similar body part. While the prosthetic is attached, it functions identically to the part it replaces. As an action, I can detach or reattach it. It can't be removed against my will. It detaches if I die."
|
||||
},
|
||||
"scribe's pen": {
|
||||
"name": "Scribe",
|
||||
"name": "Scribe's Pen",
|
||||
"type": "wondrous item",
|
||||
"rarity": "common",
|
||||
"source": [
|
||||
@@ -3805,7 +3805,7 @@
|
||||
"description": "While wearing this nondescript brooch, spells and anything else that would detect or reveal my creature type treat me as humanoid, and those that would reveal my alignment treat it as neutral."
|
||||
},
|
||||
"butcher's bib": {
|
||||
"name": "Butcher",
|
||||
"name": "Butcher's Bib",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -3890,7 +3890,7 @@
|
||||
"description": "While wearing these leather-framed goggles feature purple crystal lenses, I have advantage on Intelligence (Arcana) checks made to reveal information about a creature or object I can see. Once per dawn, I can cast Identify using the goggles."
|
||||
},
|
||||
"hunter's coat": {
|
||||
"name": "Hunter",
|
||||
"name": "Hunter's Coat",
|
||||
"type": "armor (leather)",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -4104,7 +4104,7 @@
|
||||
"description": "When I damage a creature with an attack using this magic weapon, the target can't regain hit points until the start of my next turn."
|
||||
},
|
||||
"danoth's visor": {
|
||||
"name": "Danoth",
|
||||
"name": "Danoth's Visor",
|
||||
"type": "wondrous item",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -4143,7 +4143,7 @@
|
||||
"weight": 13
|
||||
},
|
||||
"infiltrator's key": {
|
||||
"name": "Infiltrator",
|
||||
"name": "Infiltrator's Key",
|
||||
"type": "wondrous item",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -4211,7 +4211,7 @@
|
||||
"page": 196
|
||||
}
|
||||
],
|
||||
"description": "I gain +1 AC while riding this chariot, as do any passengers and the creatures pulling it. If this chariot is pulled by one or more flying creatures, they too can fly.\\n(The AC bonus is not added to the automation, as it is too situational.)",
|
||||
"description": "I gain +1 AC while riding this chariot, as do any passengers and the creatures pulling it. If this chariot is pulled by one or more flying creatures, they too can fly.\n(The AC bonus is not added to the automation, as it is too situational.)",
|
||||
"weight": 100
|
||||
},
|
||||
"helm of the gods": {
|
||||
@@ -4342,7 +4342,7 @@
|
||||
"weight": 50
|
||||
},
|
||||
"hook of fisher's delight": {
|
||||
"name": "Hook of Fisher",
|
||||
"name": "Hook of Fisher's Delight",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -4437,7 +4437,7 @@
|
||||
"page": 317
|
||||
}
|
||||
],
|
||||
"description": "This volume bound in white fur, sealed with a silver lock, containing velum pages with silver edges is cold to the touch. While I have this tome in my possession, I gain resistance to cold damage. It contains the Frost Fingers spell which a wizard can learn and a poem incantation ",
|
||||
"description": "This volume bound in white fur, sealed with a silver lock, containing velum pages with silver edges is cold to the touch. While I have this tome in my possession, I gain resistance to cold damage. It contains the Frost Fingers spell which a wizard can learn and a poem incantation \"Rime of the Frostmaiden\".",
|
||||
"weight": 3
|
||||
},
|
||||
"arcane propulsion armor": {
|
||||
@@ -4560,7 +4560,7 @@
|
||||
"description": "As a bonus action 3 times per day, I can become incorporeal until my next turn ends. While incorporeal, I can't be grappled or restrained, gain nonmagical bludgeoning, piercing, and slashing damage resistance, and can move through creatures or objects as difficult terrain (1d10 force damage if I end my turn in one)."
|
||||
},
|
||||
"illuminator's tattoo": {
|
||||
"name": "Illuminator",
|
||||
"name": "Illuminator's Tattoo",
|
||||
"type": "wondrous item (tattoo)",
|
||||
"rarity": "common",
|
||||
"source": [
|
||||
@@ -4688,7 +4688,7 @@
|
||||
"prerequisite": "Requires attunement by a druid or ranger"
|
||||
},
|
||||
"rhythm maker's drum": {
|
||||
"name": "Rhythm Maker",
|
||||
"name": "Rhythm Maker's Drum",
|
||||
"type": "wondrous item (instrument)",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -4697,7 +4697,7 @@
|
||||
"page": 134
|
||||
}
|
||||
],
|
||||
"description": "While holding this drum, I gain a bonus to spell attack rolls and to the spell saving throw DCs of my bard spells.\\nAs an action once per dawn, I can play the drum to regain one use of my Bardic Inspiration feature.",
|
||||
"description": "While holding this drum, I gain a bonus to spell attack rolls and to the spell saving throw DCs of my bard spells.\nAs an action once per dawn, I can play the drum to regain one use of my Bardic Inspiration feature.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a bard"
|
||||
},
|
||||
@@ -4711,7 +4711,7 @@
|
||||
"page": 119
|
||||
}
|
||||
],
|
||||
"description": "I can use this tome with spells as my spellbook and spellcasting focus. It has 3 charges, regaining 1d3 at dawn. With 1 charge \\u0026 1 min of study, I can change a prepared spell to a transmutation spell within. As an action, I can touch an unattended, nonmagical object and use charges to transform it into another. See tooltip.",
|
||||
"description": "I can use this tome with spells as my spellbook and spellcasting focus. It has 3 charges, regaining 1d3 at dawn. With 1 charge & 1 min of study, I can change a prepared spell to a transmutation spell within. As an action, I can touch an unattended, nonmagical object and use charges to transform it into another. See tooltip.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4725,7 +4725,7 @@
|
||||
"page": 120
|
||||
}
|
||||
],
|
||||
"description": "As bonus action, I can (un)fold this disc into an armillary sphere. I can use it as a spellcasting focus and spellbook with 3 charges, regains 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can swap a prepared spell for a divination spell within. As a reaction, I can use 1 charge to add/subtract d4 from attack/check/save in 30 ft.",
|
||||
"description": "As bonus action, I can (un)fold this disc into an armillary sphere. I can use it as a spellcasting focus and spellbook with 3 charges, regains 1d3 at dawn. For 1 charge & 1 min of study, I can swap a prepared spell for a divination spell within. As a reaction, I can use 1 charge to add/subtract d4 from attack/check/save in 30 ft.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4739,7 +4739,7 @@
|
||||
"page": 120
|
||||
}
|
||||
],
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to a conjuration spell within. As a reaction when hit by an attack, I can use 1 charge to teleport up to 10 ft, making it miss if I'm out of range.",
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to a conjuration spell within. As a reaction when hit by an attack, I can use 1 charge to teleport up to 10 ft, making it miss if I'm out of range.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4753,7 +4753,7 @@
|
||||
"page": 124
|
||||
}
|
||||
],
|
||||
"description": "I can use this orb with spells as a wizard spellcasting focus and spellbook. It lets me use Mage Hand, Mind Sliver, and Message. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to another within. I can use 1 charge to ignore components of a wizard spell (max 100 gp).",
|
||||
"description": "I can use this orb with spells as a wizard spellcasting focus and spellbook. It lets me use Mage Hand, Mind Sliver, and Message. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to another within. I can use 1 charge to ignore components of a wizard spell (max 100 gp).",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4767,7 +4767,7 @@
|
||||
"page": 126
|
||||
}
|
||||
],
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to an illusion spell within. As a reaction when a save or Investigation check is made vs. my illusion spells, I can use 1 charge to impose disadv.",
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to an illusion spell within. As a reaction when a save or Investigation check is made vs. my illusion spells, I can use 1 charge to impose disadv.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4781,12 +4781,12 @@
|
||||
"page": 128
|
||||
}
|
||||
],
|
||||
"description": "I can use this tome as a wizard spellcasting focus and spellbook. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min study, I can change a prepared spell to an evocation spell within. As a reaction when my evocation spell damages a creature, I can use 1 charge to deal it 2d6 force damage and knock it prone.",
|
||||
"description": "I can use this tome as a wizard spellcasting focus and spellbook. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min study, I can change a prepared spell to an evocation spell within. As a reaction when my evocation spell damages a creature, I can use 1 charge to deal it 2d6 force damage and knock it prone.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
"heart weaver's primer": {
|
||||
"name": "Heart Weaver",
|
||||
"name": "Heart Weaver's Primer",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -4795,7 +4795,7 @@
|
||||
"page": 128
|
||||
}
|
||||
],
|
||||
"description": "I can use this book as a wizard spellcasting focus and spellbook. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to an enchantment spell in it. When I cast an enchantment spell, I can use 1 charge to grant disadv. on the first save one target makes against the spell.",
|
||||
"description": "I can use this book as a wizard spellcasting focus and spellbook. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to an enchantment spell in it. When I cast an enchantment spell, I can use 1 charge to grant disadv. on the first save one target makes against the spell.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4809,12 +4809,12 @@
|
||||
"page": 129
|
||||
}
|
||||
],
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to a necromancy spell within. As an action, I can use 1 charge to appear undead for 10 min, causing undead I haven't damage to be indifferent.",
|
||||
"description": "This spellbook starts with 7 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to a necromancy spell within. As an action, I can use 1 charge to appear undead for 10 min, causing undead I haven't damage to be indifferent.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
"planecaller's codex": {
|
||||
"name": "Planecaller",
|
||||
"name": "Planecaller's Codex",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -4823,7 +4823,7 @@
|
||||
"page": 134
|
||||
}
|
||||
],
|
||||
"description": "This spellbook starts with 6 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge \\u0026 1 min of study, I can change a prepared spell to a conjuration spell within. When I cast a conjuration spell to summon or create one creature, I can give it adv. on attacks for 1 min for 1 charge.",
|
||||
"description": "This spellbook starts with 6 spells and is a wizard spellcasting focus. It has 3 charges, regaining 1d3 at dawn. For 1 charge & 1 min of study, I can change a prepared spell to a conjuration spell within. When I cast a conjuration spell to summon or create one creature, I can give it adv. on attacks for 1 min for 1 charge.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4837,7 +4837,7 @@
|
||||
"page": 134
|
||||
}
|
||||
],
|
||||
"description": "I can use this book with an iron lock as a spellcasting focus and spellbook. As an action, I can use Arcane Lock it. It has 3 charges, regains 1d3 at dawn. For 1 charge \\u0026 1 min study, I can change a prepared spell to an abjuration within. I can use 1 charge when I cast an abjuration spell to give a creature in 30 ft 2d10 temp HP.",
|
||||
"description": "I can use this book with an iron lock as a spellcasting focus and spellbook. As an action, I can use Arcane Lock it. It has 3 charges, regains 1d3 at dawn. For 1 charge & 1 min study, I can change a prepared spell to an abjuration within. I can use 1 charge when I cast an abjuration spell to give a creature in 30 ft 2d10 temp HP.",
|
||||
"weight": 3,
|
||||
"prerequisite": "Requires attunement by a wizard"
|
||||
},
|
||||
@@ -4879,7 +4879,7 @@
|
||||
"page": 127
|
||||
}
|
||||
],
|
||||
"description": "As an action, I can attach/detach this crystal to an object. While I hold or wear it, it works as a spellcasting focus for my sorcerer spells, and when I use a Metamagic option, I can have a creature I can see in 30 ft make a Cha save (my spell save DC) or take 3d6 psychic damage \\u0026 be frightened of me until my next turn starts.",
|
||||
"description": "As an action, I can attach/detach this crystal to an object. While I hold or wear it, it works as a spellcasting focus for my sorcerer spells, and when I use a Metamagic option, I can have a creature I can see in 30 ft make a Cha save (my spell save DC) or take 3d6 psychic damage & be frightened of me until my next turn starts.",
|
||||
"weight": 1,
|
||||
"prerequisite": "Requires attunement by a sorcerer"
|
||||
},
|
||||
@@ -4926,7 +4926,7 @@
|
||||
"prerequisite": "Requires attunement by a sorcerer"
|
||||
},
|
||||
"reveler's concertina": {
|
||||
"name": "Reveler",
|
||||
"name": "Reveler's Concertina",
|
||||
"type": "wondrous item (instrument)",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -4935,7 +4935,7 @@
|
||||
"page": 134
|
||||
}
|
||||
],
|
||||
"description": "While holding this concertina, I gain a +2 bonus to the saving throw DC of my bard spells.\\nOnce per dawn, I can use the concertina to cast Otto's Irresistible Dance.",
|
||||
"description": "While holding this concertina, I gain a +2 bonus to the saving throw DC of my bard spells.\nOnce per dawn, I can use the concertina to cast Otto's Irresistible Dance.",
|
||||
"prerequisite": "Requires attunement by a bard"
|
||||
},
|
||||
"lyre of building": {
|
||||
@@ -4979,7 +4979,7 @@
|
||||
"prerequisite": "Requires attunement by a druid or warlock"
|
||||
},
|
||||
"devotee's censer": {
|
||||
"name": "Devotee",
|
||||
"name": "Devotee's Censer",
|
||||
"type": "weapon",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -5006,7 +5006,7 @@
|
||||
"prerequisite": "Requires attunement by a cleric or paladin"
|
||||
},
|
||||
"nature's mantle": {
|
||||
"name": "Nature",
|
||||
"name": "Nature's Mantle",
|
||||
"type": "wonderous item",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -5056,7 +5056,7 @@
|
||||
"weight": 45
|
||||
},
|
||||
"serpent's fang": {
|
||||
"name": "Serpent",
|
||||
"name": "Serpent's Fang",
|
||||
"type": "weapon (longsword)",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -5115,10 +5115,11 @@
|
||||
"source": "CM",
|
||||
"page": 210
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "Unlike most scrolls, a Nether Scroll of Azumar is not a consumable magic item. It takes 30 days of concentrated study\u2014at least 8 hours per day\u2014to attempt to understand this scroll. After completing this study, you must make a DC 25 Intelligence (Arcana) check. If this check fails, you take 16d10 psychic damage, and you can attempt the check again after another 30 days of concentrated study."
|
||||
},
|
||||
"harkon's bite": {
|
||||
"name": "Harkon",
|
||||
"name": "Harkon's Bite",
|
||||
"type": "wondrous item",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -5267,7 +5268,7 @@
|
||||
"prerequisite": "Requires attunement by a non-evil creature"
|
||||
},
|
||||
"woodcutter's axe": {
|
||||
"name": "Woodcutter",
|
||||
"name": "Woodcutter's Axe",
|
||||
"type": "weapon (greataxe)",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -5558,7 +5559,7 @@
|
||||
"prerequisite": "Requires attunement by a spellcaster"
|
||||
},
|
||||
"murgaxor's orb": {
|
||||
"name": "Murgaxor",
|
||||
"name": "Murgaxor's Orb",
|
||||
"type": "wondrous item",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -5826,7 +5827,7 @@
|
||||
"weight": 25
|
||||
},
|
||||
"constantori's portrait": {
|
||||
"name": "Constantori",
|
||||
"name": "Constantori's Portrait",
|
||||
"type": "wondrous item",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -5912,7 +5913,7 @@
|
||||
"description": "When I hit a creature with an attack roll while wearing this jagged icon circlet, I can spend one HD to have the attack deal that much extra psychic damage. As an action once per dawn, I can invoke its enemy rune to cast Fear (DC 15) with a duration of 1 minute without requiring concentration."
|
||||
},
|
||||
"delver's claws": {
|
||||
"name": "Delver",
|
||||
"name": "Delver's Claws",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -5933,7 +5934,7 @@
|
||||
"page": 112
|
||||
}
|
||||
],
|
||||
"description": "This set of 1d4+2 paint pots can each be used to draw one rune on a creature in 10 min, which lasts for 8 hours: \\u2022 No penalty from difficult terrain. \\u2022 10 temp hp and adv. on death saves. \\u2022 +30 ft darkvision. \\u2022 Can't be knocked prone and adv. on Str saves and Con saves. \\u2022 Adv. on Dex save vs. damaging effects."
|
||||
"description": "This set of 1d4+2 paint pots can each be used to draw one rune on a creature in 10 min, which lasts for 8 hours: \u2022 No penalty from difficult terrain. \u2022 10 temp hp and adv. on death saves. \u2022 +30 ft darkvision. \u2022 Can't be knocked prone and adv. on Str saves and Con saves. \u2022 Adv. on Dex save vs. damaging effects."
|
||||
},
|
||||
"harp of gilded plenty": {
|
||||
"name": "Harp of Gilded Plenty",
|
||||
@@ -6021,7 +6022,7 @@
|
||||
"page": 114
|
||||
}
|
||||
],
|
||||
"description": "I can use this polished stone orb as a spellcasting focus that grants me +2 to concentration saves and ",
|
||||
"description": "I can use this polished stone orb as a spellcasting focus that grants me +2 to concentration saves and \"Divine Sight\", the ability to see in normal and magical darkness out to 120 ft. It has 3 charges per dawn. When I cast a spell, I can expend charges to ignore 300 gp worth of material components per charge used.",
|
||||
"weight": 8,
|
||||
"prerequisite": "Requires attunement by a spellcaster"
|
||||
},
|
||||
@@ -6038,7 +6039,7 @@
|
||||
"description": "As an action, I can speak the command word and throw one or more statuettes to an unoccupied space within 60 ft where it becomes a specific creature for a certain amount of time. It is friendly, understands my languages, and obeys my commands."
|
||||
},
|
||||
"reaper's scream": {
|
||||
"name": "Reaper",
|
||||
"name": "Reaper's Scream",
|
||||
"type": "weapon (morningstar)",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -6084,7 +6085,7 @@
|
||||
"page": 116
|
||||
}
|
||||
],
|
||||
"description": "As a bonus action once per dawn, I can activate this iron shield to grant me the following for 1 minute: \\u2022 Immune to fire. \\u2022 As an action, I can remove disease, blinded, charmed, deafened, or poisoned from myself a creature I can see within 30 ft (Cleansing Fire). \\u2022 I can make a shield bash attack once per turn (see attack).",
|
||||
"description": "As a bonus action once per dawn, I can activate this iron shield to grant me the following for 1 minute: \u2022 Immune to fire. \u2022 As an action, I can remove disease, blinded, charmed, deafened, or poisoned from myself a creature I can see within 30 ft (Cleansing Fire). \u2022 I can make a shield bash attack once per turn (see attack).",
|
||||
"weight": 6
|
||||
},
|
||||
"staff of the rooted hills": {
|
||||
@@ -6101,7 +6102,7 @@
|
||||
"weight": 4
|
||||
},
|
||||
"stonebreaker's breastplate": {
|
||||
"name": "Stonebreaker",
|
||||
"name": "Stonebreaker's Breastplate",
|
||||
"type": "armor (breastplate)",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -6139,7 +6140,7 @@
|
||||
"description": "As a bonus action, I can blow this brass war horn with the war rune to stop being frightened and gain adv. on saves against being frightened until my next turn starts. Once per dawn when I blow it, I can also invoke its rune, imbuing all chosen creatures within 30 ft with a +1 bonus to AC until my next turn starts."
|
||||
},
|
||||
"wayfarer's boots": {
|
||||
"name": "Wayfarer",
|
||||
"name": "Wayfarer's Boots",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -6531,7 +6532,7 @@
|
||||
"description": "This ring has 3 charges, which are restored whenever I finish a long rest. As a reaction when I am damaged, I can expend one charge to transfer that damage to a random creature (allies included) within 60 ft of me."
|
||||
},
|
||||
"sage's mirror": {
|
||||
"name": "Sage",
|
||||
"name": "Sage's Mirror",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -6752,7 +6753,7 @@
|
||||
"weight": 20
|
||||
},
|
||||
"card sharp's deck": {
|
||||
"name": "Card Sharp",
|
||||
"name": "Card Sharp's Deck",
|
||||
"type": "wondrous item",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -6761,7 +6762,7 @@
|
||||
"page": 40
|
||||
}
|
||||
],
|
||||
"description": "The cards of this deck shimmer around the edges. As an action, I can throw a card as ranged spell attack using Dexterity. This "
|
||||
"description": "The cards of this deck shimmer around the edges. As an action, I can throw a card as ranged spell attack using Dexterity. This \"Deadly Deal\" attack has 120 ft range and deals 1d8 force damage. As an action once per dawn, I can shuffle the deck to cast Spray of Cards at 3rd level with it (save DC 15)."
|
||||
},
|
||||
"clockwork armor": {
|
||||
"name": "Clockwork Armor",
|
||||
@@ -6872,7 +6873,7 @@
|
||||
"description": "If a creature is hit by this magic ammunition, the leech animates and attaches to the target, dealing 1d4 piercing damage at the start of each of their turns. The leech detaches if it deals at least 10 damage or the target dies. Anyone can use their action to detach a leech. A detached leech dies and turns nonmagical."
|
||||
},
|
||||
"euryale's aegis": {
|
||||
"name": "Euryale",
|
||||
"name": "Euryale's Aegis",
|
||||
"type": "shield",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -6910,7 +6911,7 @@
|
||||
"weight": 1
|
||||
},
|
||||
"fate dealer's deck": {
|
||||
"name": "Fate Dealer",
|
||||
"name": "Fate Dealer's Deck",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -7009,7 +7010,7 @@
|
||||
"description": "As an action once per dawn, I can have this deck of cards deal themselves into a shelter centered on a point within 30 ft. It can be any shape that fits in a 40-ft cube, with 1 door and up to 4 windows, that only I can open or close. It has 15 AC, 50 HP, and lasts for 24 hours, I dismiss it as an action, or it reaches 0 HP."
|
||||
},
|
||||
"jester's mask": {
|
||||
"name": "Jester",
|
||||
"name": "Jester's Mask",
|
||||
"type": "wondrous item",
|
||||
"rarity": "legendary",
|
||||
"source": [
|
||||
@@ -7022,7 +7023,7 @@
|
||||
"prerequisite": "Requires attunement by a bard, sorcerer, or warlock"
|
||||
},
|
||||
"plate of knight's fellowship": {
|
||||
"name": "Plate of Knight",
|
||||
"name": "Plate of Knight's Fellowship",
|
||||
"type": "armor (plate)",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -7035,7 +7036,7 @@
|
||||
"weight": 65
|
||||
},
|
||||
"ring of puzzler's wit": {
|
||||
"name": "Ring of Puzzler",
|
||||
"name": "Ring of Puzzler's Wit",
|
||||
"type": "ring",
|
||||
"rarity": "uncommon",
|
||||
"source": [
|
||||
@@ -7061,7 +7062,7 @@
|
||||
"prerequisite": "Requires attunement by a spellcaster"
|
||||
},
|
||||
"rogue's mantle": {
|
||||
"name": "Rogue",
|
||||
"name": "Rogue's Mantle",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
@@ -7086,7 +7087,7 @@
|
||||
"weight": 2
|
||||
},
|
||||
"sage's signet": {
|
||||
"name": "Sage",
|
||||
"name": "Sage's Signet",
|
||||
"type": "ring",
|
||||
"rarity": "very rare",
|
||||
"source": [
|
||||
@@ -7250,7 +7251,7 @@
|
||||
"weight": 13
|
||||
},
|
||||
"warrior's passkey": {
|
||||
"name": "Warrior",
|
||||
"name": "Warrior's Passkey",
|
||||
"type": "wondrous item",
|
||||
"rarity": "rare",
|
||||
"source": [
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"name": "Acquisitions Incorporated"
|
||||
},
|
||||
"DiA": {
|
||||
"name": "Baldur"
|
||||
"name": "Baldur's Gate: Descent into Avernus [background, items]"
|
||||
},
|
||||
"AwM": {
|
||||
"name": "Adventure with Muk"
|
||||
@@ -75,6 +75,6 @@
|
||||
"name": "Quests from the Infinite Staircase"
|
||||
},
|
||||
"ALPGs9": {
|
||||
"name": "AL Player"
|
||||
"name": "AL Player's Guide v9.1: Inglorious Redemption"
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"sorcerer",
|
||||
@@ -363,7 +363,7 @@
|
||||
]
|
||||
},
|
||||
"crusader's mantle": {
|
||||
"name": "Crusader",
|
||||
"name": "Crusader's Mantle",
|
||||
"source": [
|
||||
{
|
||||
"source": "P",
|
||||
@@ -389,7 +389,7 @@
|
||||
"page": 231
|
||||
}
|
||||
],
|
||||
"description": "Any crea 5d6 Thunder dmg \\u0026 5d6 Radiant or Necrotic dmg \\u0026 knocked prone; save halves, not prone",
|
||||
"description": "Any crea 5d6 Thunder dmg & 5d6 Radiant or Necrotic dmg & knocked prone; save halves, not prone",
|
||||
"level": 5,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -794,7 +794,7 @@
|
||||
"page": 284
|
||||
}
|
||||
],
|
||||
"description": "300\\xD750\\xD7300ft (l\\xD7w\\xD7h) wall of water moves away at 50 ft/rnd; 6d10 Bludg. dmg; save halves; see B",
|
||||
"description": "300\u00d750\u00d7300ft (l\u00d7w\u00d7h) wall of water moves away at 50 ft/rnd; 6d10 Bludg. dmg; save halves; see B",
|
||||
"level": 8,
|
||||
"school": "Conj",
|
||||
"time": "1 min",
|
||||
@@ -848,7 +848,7 @@
|
||||
]
|
||||
},
|
||||
"abi-dalzim's horrid wilting": {
|
||||
"name": "Abi-Dalzim",
|
||||
"name": "Abi-Dalzim's Horrid Wilting",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -900,7 +900,7 @@
|
||||
]
|
||||
},
|
||||
"aganazzar's scorcher": {
|
||||
"name": "Aganazzar",
|
||||
"name": "Aganazzar's Scorcher",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -960,7 +960,7 @@
|
||||
"page": 15
|
||||
}
|
||||
],
|
||||
"description": "6+2/SL 5-ft dia stone lift up 30 ft; \\u2265Medium crea save or lifted, 6d6 Bludg. dmg if hit ceiling; see B",
|
||||
"description": "6+2/SL 5-ft dia stone lift up 30 ft; \u2265Medium crea save or lifted, 6d6 Bludg. dmg if hit ceiling; see B",
|
||||
"level": 6,
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"page": 17
|
||||
}
|
||||
],
|
||||
"description": "1+1/SL crea, each max 30 ft apart, save or 1 energy: lose resist. to it \\u0026 +2d6 to first dmg with it/turn",
|
||||
"description": "1+1/SL crea, each max 30 ft apart, save or 1 energy: lose resist. to it & +2d6 to first dmg with it/turn",
|
||||
"level": 4,
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
@@ -1344,7 +1344,7 @@
|
||||
"page": 19
|
||||
}
|
||||
],
|
||||
"description": "1 crea save or 8d6 Fire dmg \\u0026 burns for 4d6 Fire dmg/rnd; save each rnd to end; save half, no burning",
|
||||
"description": "1 crea save or 8d6 Fire dmg & burns for 4d6 Fire dmg/rnd; save each rnd to end; save half, no burning",
|
||||
"level": 5,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -1515,7 +1515,7 @@
|
||||
]
|
||||
},
|
||||
"maximilian's earthen grasp": {
|
||||
"name": "Maximilian",
|
||||
"name": "Maximilian's Earthen Grasp",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -1526,7 +1526,7 @@
|
||||
"page": 20
|
||||
}
|
||||
],
|
||||
"description": "Medium hand atks 1 crea: save or 2d6 Bludg. dmg \\u0026 restrained; 1 a hand moves/atks, releases; see B",
|
||||
"description": "Medium hand atks 1 crea: save or 2d6 Bludg. dmg & restrained; 1 a hand moves/atks, releases; see B",
|
||||
"level": 2,
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
@@ -1540,7 +1540,7 @@
|
||||
]
|
||||
},
|
||||
"melf's minute meteors": {
|
||||
"name": "Melf",
|
||||
"name": "Melf's Minute Meteors",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -1696,7 +1696,7 @@
|
||||
]
|
||||
},
|
||||
"snilloc's snowball swarm": {
|
||||
"name": "Snilloc",
|
||||
"name": "Snilloc's Snowball Swarm",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -1837,7 +1837,7 @@
|
||||
"page": 23
|
||||
}
|
||||
],
|
||||
"description": "20-ft rad all crea 10d4+2d4/SL Acid dmg, +5d4 dmg next turn end; save half \\u0026 no dmg next turn",
|
||||
"description": "20-ft rad all crea 10d4+2d4/SL Acid dmg, +5d4 dmg next turn end; save half & no dmg next turn",
|
||||
"level": 4,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -1862,7 +1862,7 @@
|
||||
"page": 23
|
||||
}
|
||||
],
|
||||
"description": "30\\xD710\\xD710ft (l\\xD7w\\xD7h) wall on the ground; blocks line of sight; blinded while inside; 1/3 move",
|
||||
"description": "30\u00d710\u00d710ft (l\u00d7w\u00d7h) wall on the ground; blocks line of sight; blinded while inside; 1/3 move",
|
||||
"level": 3,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -1893,7 +1893,7 @@
|
||||
"page": 27
|
||||
}
|
||||
],
|
||||
"description": "30\\xD71\\xD710ft (l\\xD7w\\xD7h) or 20-ft rad 20-ft high; dif. ter.; range wea dis.; Fire dmg half; Cold dmg freezes",
|
||||
"description": "30\u00d71\u00d710ft (l\u00d7w\u00d7h) or 20-ft rad 20-ft high; dif. ter.; range wea dis.; Fire dmg half; Cold dmg freezes",
|
||||
"level": 3,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -1996,12 +1996,12 @@
|
||||
"page": 142
|
||||
}
|
||||
],
|
||||
"description": "Melee wea atk with cast; hit: 0d8 Thunder dmg, if it moves next round +1d8; +1d8 at CL5, 11, \\u0026 17",
|
||||
"description": "Melee wea atk with cast; hit: 0d8 Thunder dmg, if it moves next round +1d8; +1d8 at CL5, 11, & 17",
|
||||
"level": 0,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
"range": "S:5-ft rad",
|
||||
"components": "S,M\\u0192",
|
||||
"components": "S,M\u0192",
|
||||
"duration": "1 round",
|
||||
"classes": [
|
||||
"artificer",
|
||||
@@ -2027,7 +2027,7 @@
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
"range": "S:5-ft rad",
|
||||
"components": "S,M\\u0192",
|
||||
"components": "S,M\u0192",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"artificer",
|
||||
@@ -2048,7 +2048,7 @@
|
||||
"page": 143
|
||||
}
|
||||
],
|
||||
"description": "1 crea in 15 ft save or pulled 10 ft to me; if it ends in 5 ft, 1d8 Lightning dmg; +1d8 at CL 5, 11, \\u0026 17",
|
||||
"description": "1 crea in 15 ft save or pulled 10 ft to me; if it ends in 5 ft, 1d8 Lightning dmg; +1d8 at CL 5, 11, & 17",
|
||||
"level": 0,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -2146,7 +2146,7 @@
|
||||
"school": "Abjur",
|
||||
"time": "1 h",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"ritual": true,
|
||||
"classes": [
|
||||
@@ -2214,7 +2214,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 h",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"wizard"
|
||||
@@ -2274,7 +2274,7 @@
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"save": "Con",
|
||||
"classes": [
|
||||
@@ -2283,7 +2283,7 @@
|
||||
]
|
||||
},
|
||||
"dragon's breath": {
|
||||
"name": "Dragon",
|
||||
"name": "Dragon's Breath",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -2316,7 +2316,7 @@
|
||||
"school": "Abjur",
|
||||
"time": "10 min",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "24 h",
|
||||
"classes": [
|
||||
"druid"
|
||||
@@ -2476,7 +2476,7 @@
|
||||
"page": 157
|
||||
}
|
||||
],
|
||||
"description": "Huge shadowy dragon; see: Wis save or fright.; bns a move 60 ft \\u0026 breath wea 7d6 dmg; Int save half",
|
||||
"description": "Huge shadowy dragon; see: Wis save or fright.; bns a move 60 ft & breath wea 7d6 dmg; Int save half",
|
||||
"level": 8,
|
||||
"school": "Illus",
|
||||
"time": "1 a",
|
||||
@@ -2501,7 +2501,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 min",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -2544,7 +2544,7 @@
|
||||
"school": "Abjur",
|
||||
"time": "1 a",
|
||||
"range": "Self",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Conc, 10 min",
|
||||
"classes": [
|
||||
"wizard"
|
||||
@@ -2599,7 +2599,7 @@
|
||||
"page": 160
|
||||
}
|
||||
],
|
||||
"description": "10 crea save or take chosen beast form of CR \\u2264 its CR or half its char. level; can only act as beast; see B",
|
||||
"description": "10 crea save or take chosen beast form of CR \u2264 its CR or half its char. level; can only act as beast; see B",
|
||||
"level": 9,
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
@@ -2648,7 +2648,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 min",
|
||||
"range": "1 mile",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"wizard"
|
||||
@@ -2816,7 +2816,7 @@
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
"range": "Self",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"classes": [
|
||||
"warlock"
|
||||
@@ -2879,7 +2879,7 @@
|
||||
"school": "Abjur",
|
||||
"time": "1 min",
|
||||
"range": "Touch",
|
||||
"components": "S,M\\u2020",
|
||||
"components": "S,M\u2020",
|
||||
"duration": "8 h, till trigger",
|
||||
"save": "Dex",
|
||||
"classes": [
|
||||
@@ -2902,7 +2902,7 @@
|
||||
"school": "Necro",
|
||||
"time": "1 rea",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "8 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -2922,7 +2922,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "30 ft",
|
||||
"components": "S,M\\u0192",
|
||||
"components": "S,M\u0192",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"ranger",
|
||||
@@ -2958,7 +2958,7 @@
|
||||
"page": 167
|
||||
}
|
||||
],
|
||||
"description": "Summon up to 8 (16 at SL6, 24 at SL8) CR \\u22641 1 demons, DM choice; attack nearest non-demons",
|
||||
"description": "Summon up to 8 (16 at SL6, 24 at SL8) CR \u22641 1 demons, DM choice; attack nearest non-demons",
|
||||
"level": 3,
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
@@ -3006,14 +3006,14 @@
|
||||
"school": "Conj",
|
||||
"time": "1 h",
|
||||
"range": "120 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "24 h",
|
||||
"classes": [
|
||||
"cleric"
|
||||
]
|
||||
},
|
||||
"tenser's transformation": {
|
||||
"name": "Tenser",
|
||||
"name": "Tenser's Transformation",
|
||||
"source": [
|
||||
{
|
||||
"source": "X",
|
||||
@@ -3085,7 +3085,7 @@
|
||||
"page": 4
|
||||
}
|
||||
],
|
||||
"description": "1 crea save or 1d12 Necrotic dmg (d8 instead of d12 if at full HP); +1d12/1d8 at CL 5, 11, \\u0026 17",
|
||||
"description": "1 crea save or 1d12 Necrotic dmg (d8 instead of d12 if at full HP); +1d12/1d8 at CL 5, 11, & 17",
|
||||
"level": 0,
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
@@ -3107,7 +3107,7 @@
|
||||
"page": 170
|
||||
}
|
||||
],
|
||||
"description": "60\\xD75\\xD710ft (l\\xD7w\\xD7h) 4d8+1d8/SL Radiant dmg \\u0026 blind; save half, not blind; 1 a rngd spell atk; see B",
|
||||
"description": "60\u00d75\u00d710ft (l\u00d7w\u00d7h) 4d8+1d8/SL Radiant dmg & blind; save half, not blind; 1 a rngd spell atk; see B",
|
||||
"level": 5,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -3201,19 +3201,19 @@
|
||||
]
|
||||
},
|
||||
"galder's speedy courier": {
|
||||
"name": "Galder",
|
||||
"name": "Galder's Speedy Courier",
|
||||
"source": [
|
||||
{
|
||||
"source": "LLoK",
|
||||
"page": 57
|
||||
}
|
||||
],
|
||||
"description": "Send 3\\xD73\\xD73 ft chest of items I put in it to named crea on same plane; SL8: other plane (25gp cons.)",
|
||||
"description": "Send 3\u00d73\u00d73 ft chest of items I put in it to named crea on same plane; SL8: other plane (25gp cons.)",
|
||||
"level": 4,
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "10 ft",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "10 min",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -3221,7 +3221,7 @@
|
||||
]
|
||||
},
|
||||
"galder's tower": {
|
||||
"name": "Galder",
|
||||
"name": "Galder's Tower",
|
||||
"source": [
|
||||
{
|
||||
"source": "LLoK",
|
||||
@@ -3247,7 +3247,7 @@
|
||||
"page": 47
|
||||
}
|
||||
],
|
||||
"description": "Make physical thought strand of memory or vice versa; works with detect thoughts \\u0026 modify memory",
|
||||
"description": "Make physical thought strand of memory or vice versa; works with detect thoughts & modify memory",
|
||||
"level": 0,
|
||||
"school": "Ench",
|
||||
"time": "1 a",
|
||||
@@ -3313,7 +3313,7 @@
|
||||
"school": "Ench",
|
||||
"time": "1 rea",
|
||||
"range": "Self",
|
||||
"components": "V,S,R\\u2020",
|
||||
"components": "V,S,R\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"bard",
|
||||
@@ -3333,7 +3333,7 @@
|
||||
"school": "Ench",
|
||||
"time": "1 a",
|
||||
"range": "30 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"save": "Wis",
|
||||
"classes": [
|
||||
@@ -3343,7 +3343,7 @@
|
||||
]
|
||||
},
|
||||
"jim's glowing coin": {
|
||||
"name": "Jim",
|
||||
"name": "Jim's Glowing Coin",
|
||||
"source": [
|
||||
{
|
||||
"source": "AcqInc",
|
||||
@@ -3355,7 +3355,7 @@
|
||||
"school": "Ench",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,M,R\\u2020",
|
||||
"components": "V,M,R\u2020",
|
||||
"duration": "1 min",
|
||||
"save": "Wis",
|
||||
"classes": [
|
||||
@@ -3363,7 +3363,7 @@
|
||||
]
|
||||
},
|
||||
"jim's magic missile": {
|
||||
"name": "Jim",
|
||||
"name": "Jim's Magic Missile",
|
||||
"source": [
|
||||
{
|
||||
"source": "AcqInc",
|
||||
@@ -3375,7 +3375,7 @@
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
"range": "120 ft",
|
||||
"components": "V,S,R\\u2020",
|
||||
"components": "V,S,R\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"wizard"
|
||||
@@ -3444,7 +3444,7 @@
|
||||
"page": 188
|
||||
}
|
||||
],
|
||||
"description": "10-ft rad all crea 2d8+1d8/SL Force dmg, half spd; Save halves \\u0026 no spd reduce; Str check to move obj",
|
||||
"description": "10-ft rad all crea 2d8+1d8/SL Force dmg, half spd; Save halves & no spd reduce; Str check to move obj",
|
||||
"level": 1,
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
@@ -3455,7 +3455,7 @@
|
||||
"classes": []
|
||||
},
|
||||
"fortune's favor": {
|
||||
"name": "Fortune",
|
||||
"name": "Fortune's Favor",
|
||||
"source": [
|
||||
{
|
||||
"source": "W",
|
||||
@@ -3467,7 +3467,7 @@
|
||||
"school": "Div",
|
||||
"time": "1 min",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "1 h",
|
||||
"classes": []
|
||||
},
|
||||
@@ -3484,7 +3484,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "1 h",
|
||||
"classes": []
|
||||
},
|
||||
@@ -3568,7 +3568,7 @@
|
||||
"page": 187
|
||||
}
|
||||
],
|
||||
"description": "100-ft long 5-ft wide all 8d8+1d8/SL Force dmg, save half; all in 10 ft of line save or dmg \\u0026 pull to it",
|
||||
"description": "100-ft long 5-ft wide all 8d8+1d8/SL Force dmg, save half; all in 10 ft of line save or dmg & pull to it",
|
||||
"level": 6,
|
||||
"school": "Evoc",
|
||||
"time": "1 a",
|
||||
@@ -3590,7 +3590,7 @@
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Conc, 1 h",
|
||||
"save": "Con",
|
||||
"classes": []
|
||||
@@ -3657,12 +3657,12 @@
|
||||
"page": 189
|
||||
}
|
||||
],
|
||||
"description": "1 crea 10d12 Necrotic dmg \\u0026 aged: dis. atk/chk/save, die in 30 days; save half, not aged (5k gp cons.)",
|
||||
"description": "1 crea 10d12 Necrotic dmg & aged: dis. atk/chk/save, die in 30 days; save half, not aged (5k gp cons.)",
|
||||
"level": 9,
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"save": "Con",
|
||||
"classes": []
|
||||
@@ -3679,7 +3679,7 @@
|
||||
"page": 318
|
||||
}
|
||||
],
|
||||
"description": "Create weapon; 2 spell atks 4d12 Force dmg; crit on 18+, triple dmg; bns a to move 30 ft \\u0026 do 2 atks",
|
||||
"description": "Create weapon; 2 spell atks 4d12 Force dmg; crit on 18+, triple dmg; bns a to move 30 ft & do 2 atks",
|
||||
"level": 9,
|
||||
"school": "Conj",
|
||||
"time": "1 bns",
|
||||
@@ -3705,7 +3705,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 h",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"wizard"
|
||||
@@ -3744,7 +3744,7 @@
|
||||
"school": "Conj",
|
||||
"time": "10 min",
|
||||
"range": "20 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "6 hours",
|
||||
"classes": [
|
||||
"bard",
|
||||
@@ -3845,7 +3845,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -3865,7 +3865,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"druid",
|
||||
@@ -3885,7 +3885,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"cleric",
|
||||
@@ -3905,7 +3905,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"artificer",
|
||||
@@ -3925,7 +3925,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"druid",
|
||||
@@ -3946,7 +3946,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"druid",
|
||||
@@ -3968,7 +3968,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -3988,7 +3988,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -4008,7 +4008,7 @@
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
"range": "90 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"warlock",
|
||||
@@ -4016,7 +4016,7 @@
|
||||
]
|
||||
},
|
||||
"tasha's caustic brew": {
|
||||
"name": "Tasha",
|
||||
"name": "Tasha's Caustic Brew",
|
||||
"source": [
|
||||
{
|
||||
"source": "T",
|
||||
@@ -4038,7 +4038,7 @@
|
||||
]
|
||||
},
|
||||
"tasha's mind whip": {
|
||||
"name": "Tasha",
|
||||
"name": "Tasha's Mind Whip",
|
||||
"source": [
|
||||
{
|
||||
"source": "T",
|
||||
@@ -4063,7 +4063,7 @@
|
||||
]
|
||||
},
|
||||
"tasha's otherworldly guise": {
|
||||
"name": "Tasha",
|
||||
"name": "Tasha's Otherworldly Guise",
|
||||
"source": [
|
||||
{
|
||||
"source": "T",
|
||||
@@ -4075,7 +4075,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 bns",
|
||||
"range": "Self",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"classes": [
|
||||
"sorcerer",
|
||||
@@ -4084,7 +4084,7 @@
|
||||
]
|
||||
},
|
||||
"ashardalon's stride": {
|
||||
"name": "Ashardalon",
|
||||
"name": "Ashardalon's Stride",
|
||||
"source": [
|
||||
{
|
||||
"source": "FToD",
|
||||
@@ -4122,7 +4122,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 bns",
|
||||
"range": "S:60",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"save": "Dex",
|
||||
"classes": [
|
||||
@@ -4132,19 +4132,19 @@
|
||||
]
|
||||
},
|
||||
"fizban's platinum shield": {
|
||||
"name": "Fizban",
|
||||
"name": "Fizban's Platinum Shield",
|
||||
"source": [
|
||||
{
|
||||
"source": "FToD",
|
||||
"page": 20
|
||||
}
|
||||
],
|
||||
"description": "1 crea Acid, Cold, Fire, Lightn. \\u0026 Poison resist., half cover, better Dex saves; 1 bns change crea (500gp)",
|
||||
"description": "1 crea Acid, Cold, Fire, Lightn. & Poison resist., half cover, better Dex saves; 1 bns change crea (500gp)",
|
||||
"level": 6,
|
||||
"school": "Abjur",
|
||||
"time": "1 bns",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 min",
|
||||
"classes": [
|
||||
"sorcerer",
|
||||
@@ -4152,7 +4152,7 @@
|
||||
]
|
||||
},
|
||||
"nathair's mischief": {
|
||||
"name": "Nathair",
|
||||
"name": "Nathair's Mischief",
|
||||
"source": [
|
||||
{
|
||||
"source": "FToD",
|
||||
@@ -4178,14 +4178,14 @@
|
||||
]
|
||||
},
|
||||
"raulothim's psychic lance": {
|
||||
"name": "Raulothim",
|
||||
"name": "Raulothim's Psychic Lance",
|
||||
"source": [
|
||||
{
|
||||
"source": "FToD",
|
||||
"page": 21
|
||||
}
|
||||
],
|
||||
"description": "1 crea I see or can name 7d6+1d6/SL Psychic dmg \\u0026 incap. till start of my turn; save half, not incap.",
|
||||
"description": "1 crea I see or can name 7d6+1d6/SL Psychic dmg & incap. till start of my turn; save half, not incap.",
|
||||
"level": 4,
|
||||
"school": "Ench",
|
||||
"time": "1 a",
|
||||
@@ -4201,7 +4201,7 @@
|
||||
]
|
||||
},
|
||||
"rime's binding ice": {
|
||||
"name": "Rime",
|
||||
"name": "Rime's Binding Ice",
|
||||
"source": [
|
||||
{
|
||||
"source": "FToD",
|
||||
@@ -4242,7 +4242,7 @@
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"druid",
|
||||
@@ -4263,7 +4263,7 @@
|
||||
"school": "Div",
|
||||
"time": "1 a",
|
||||
"range": "Self",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "1 h",
|
||||
"classes": [
|
||||
"bard",
|
||||
@@ -4395,7 +4395,7 @@
|
||||
"school": "Trans",
|
||||
"time": "1 a",
|
||||
"range": "Touch",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "Instantaneous",
|
||||
"classes": [
|
||||
"artificer",
|
||||
@@ -4415,7 +4415,7 @@
|
||||
"school": "Abjur",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u2020",
|
||||
"components": "V,S,M\u2020",
|
||||
"duration": "24 h",
|
||||
"classes": [
|
||||
"sorcerer",
|
||||
@@ -4431,7 +4431,7 @@
|
||||
"page": 12
|
||||
}
|
||||
],
|
||||
"description": "Know presence of portals in 30 ft; 1 a DC 15 spell ability chk to see destination \\u0026 portal key, ends spell",
|
||||
"description": "Know presence of portals in 30 ft; 1 a DC 15 spell ability chk to see destination & portal key, ends spell",
|
||||
"level": 2,
|
||||
"school": "Div",
|
||||
"time": "1 a",
|
||||
@@ -4480,7 +4480,7 @@
|
||||
"school": "Necro",
|
||||
"time": "1 a",
|
||||
"range": "60 ft",
|
||||
"components": "V,S,M\\u0192",
|
||||
"components": "V,S,M\u0192",
|
||||
"duration": "Conc, 1 h",
|
||||
"classes": [
|
||||
"sorcerer",
|
||||
@@ -4496,7 +4496,7 @@
|
||||
"page": 50
|
||||
}
|
||||
],
|
||||
"description": "All in area 2d10+1d10/SL Force dmg and blinded until their next turn ends; save halves \\u0026 not blinded",
|
||||
"description": "All in area 2d10+1d10/SL Force dmg and blinded until their next turn ends; save halves & not blinded",
|
||||
"level": 2,
|
||||
"school": "Conj",
|
||||
"time": "1 a",
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cloudUsername } from '@/lib/cloud/client';
|
||||
import { getUsage } from '@/lib/cloud/campaigns';
|
||||
import { useCloudUser } from '@/features/cloud/cloudAuth';
|
||||
|
||||
// Cache across mounts so the sidebar doesn't re-ask /api/usage on every navigation.
|
||||
let cached: boolean | null = null;
|
||||
// Cache per signed-in user so the sidebar doesn't re-ask /api/usage on every navigation.
|
||||
let cached: { user: string; admin: boolean } | null = null;
|
||||
|
||||
/** Whether the signed-in cloud account is an instance admin (false while unknown). */
|
||||
/** Whether the signed-in cloud account is an instance admin (false while unknown).
|
||||
* Reacts to sign-in/out — the Admin nav entry appears/disappears without a reload. */
|
||||
export function useIsAdmin(): boolean {
|
||||
const [isAdmin, setIsAdmin] = useState(cached === true);
|
||||
const user = useCloudUser();
|
||||
const [isAdmin, setIsAdmin] = useState(!!user && cached?.user === user && cached.admin);
|
||||
useEffect(() => {
|
||||
if (cached !== null || !cloudUsername()) return;
|
||||
if (!user) { setIsAdmin(false); return; }
|
||||
if (cached?.user === user) { setIsAdmin(cached.admin); return; }
|
||||
let on = true;
|
||||
getUsage()
|
||||
.then((u) => { cached = u.admin; if (on) setIsAdmin(u.admin); })
|
||||
.then((res) => { cached = { user, admin: res.admin }; if (on) setIsAdmin(res.admin); })
|
||||
.catch(() => { /* signed out / offline — stay hidden */ });
|
||||
return () => { on = false; };
|
||||
}, []);
|
||||
return isAdmin;
|
||||
}, [user]);
|
||||
return user ? isAdmin : false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Campaign } 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 { addCombatant } from '@/lib/combat/engine';
|
||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||
import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
|
||||
@@ -73,8 +72,10 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
||||
const level = Number(mm.level);
|
||||
enc = addCombatant(enc, {
|
||||
id: newId(), name, kind: 'monster',
|
||||
initiative: rollDice('1d20', createRng()).total + initBonus, initBonus,
|
||||
// NO auto-roll: initiative stays 0 until a human rolls it (tracker "Roll all").
|
||||
initiative: 0, initBonus,
|
||||
ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '',
|
||||
...(typeof mm.slug === 'string' ? { monsterRef: mm.slug } : {}),
|
||||
...(is5e && Number.isFinite(cr) ? { cr } : {}),
|
||||
...(!is5e && Number.isFinite(level) ? { level } : {}),
|
||||
});
|
||||
@@ -120,10 +121,10 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
||||
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
||||
|
||||
<h2 className="mb-2 mt-6 smallcaps">Session hooks</h2>
|
||||
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
|
||||
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} encounters={encounters} />
|
||||
|
||||
<h2 className="mb-2 mt-6 smallcaps">Quick NPC</h2>
|
||||
<NpcGenCard campaign={campaign} />
|
||||
<NpcGenCard campaign={campaign} notes={notes} quests={quests} />
|
||||
|
||||
<h2 className="mb-2 mt-6 smallcaps">Campaign insights</h2>
|
||||
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import type { Campaign, Note, Quest } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface Props {
|
||||
campaign: Campaign;
|
||||
notes: Note[];
|
||||
quests: Quest[];
|
||||
}
|
||||
|
||||
export function NpcGenCard({ campaign }: Props) {
|
||||
export function NpcGenCard({ campaign, notes, quests }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
@@ -24,12 +26,9 @@ export function NpcGenCard({ campaign }: Props) {
|
||||
const [npc, setNpc] = useState<NpcQuick | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const ctx = {
|
||||
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
|
||||
systemLabel: getSystem(campaign.system).label,
|
||||
questsSummary: 'No active quests.',
|
||||
notesSummary: 'No notes yet.',
|
||||
};
|
||||
// Ground the NPC in the campaign's real threads — the prompt is built to weave
|
||||
// in active quests and notes, so feed it the live data, never placeholders.
|
||||
const ctx = buildCampaignContext({ campaign, characters: [], encounters: [], quests, notes });
|
||||
|
||||
const generate = async () => {
|
||||
setState('loading');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
|
||||
import type { Campaign, Character, Encounter, Note, Quest } from '@/lib/schemas';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
characters: Character[];
|
||||
notes: Note[];
|
||||
quests: Quest[];
|
||||
encounters: Encounter[];
|
||||
}
|
||||
|
||||
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
|
||||
export function SessionPrepCard({ campaign, characters, notes, quests, encounters }: Props) {
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
@@ -26,7 +27,8 @@ export function SessionPrepCard({ campaign, characters, notes, quests }: Props)
|
||||
const generate = async () => {
|
||||
setState('loading');
|
||||
setMessage(null);
|
||||
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
|
||||
// Real encounters, so the prompt's "Recent encounters" grounding line can appear.
|
||||
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
|
||||
|
||||
if (canUseLlm) {
|
||||
const { system, user } = buildSessionHookPrompt(ctx);
|
||||
|
||||
@@ -70,7 +70,8 @@ function Director({ campaign }: { campaign: Campaign }) {
|
||||
<div className="space-y-2">
|
||||
<div className="smallcaps">Rolls — you roll these</div>
|
||||
{lastTurn.rollRequests.map((r) => (
|
||||
<RollRequestCard key={r.id} req={r} system={campaign.system} onRolled={recordRoll} />
|
||||
// Turn id in the key too: the model may reuse ids ("1", "2") across turns.
|
||||
<RollRequestCard key={`${lastTurn.id}:${r.id}`} req={r} system={campaign.system} onRolled={recordRoll} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -79,7 +80,9 @@ function Director({ campaign }: { campaign: Campaign }) {
|
||||
<div className="space-y-2">
|
||||
<div className="smallcaps">Proposed changes — you approve each</div>
|
||||
{lastTurn.actions.map((a, i) => (
|
||||
<ActionCard key={i} action={a} onApply={applyAction} />
|
||||
// Keyed by the TURN's identity: a new turn's card at the same index must
|
||||
// remount, or the previous card's applied/skipped state would leak onto it.
|
||||
<ActionCard key={`${lastTurn.id}:${i}`} action={a} onApply={applyAction} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,12 +23,16 @@ export function RollRequestCard({
|
||||
}) {
|
||||
const [done, setDone] = useState<{ total: number; degree?: Degree } | null>(null);
|
||||
|
||||
const [badExpression, setBadExpression] = useState(false);
|
||||
const roll = () => {
|
||||
const result = rollAndShow({
|
||||
expression: req.expression,
|
||||
label: req.label,
|
||||
...(req.dc !== undefined ? { dc: req.dc, system } : {}),
|
||||
...(req.dc !== undefined ? { dc: req.dc, system, rollKind: req.kind } : {}),
|
||||
});
|
||||
// The expression comes from the LLM — if it doesn't parse, say so instead of
|
||||
// a dead button (and don't report a roll that never happened).
|
||||
if (!result) { setBadExpression(true); return; }
|
||||
setDone(result);
|
||||
onRolled(req, result);
|
||||
};
|
||||
@@ -51,6 +55,8 @@ export function RollRequestCard({
|
||||
{done.total}
|
||||
{done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''}
|
||||
</Badge>
|
||||
) : badExpression ? (
|
||||
<Badge tone="ember">Unrollable — roll it yourself</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={roll}>Roll</Button>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
||||
import { updateCombatant } from '@/lib/combat/engine';
|
||||
import { buildDirectorScene, type SceneActor } from '@/lib/assistant/director';
|
||||
import { characterSchema, encounterSchema, newSpellEntry, type Campaign, type Character, type Encounter } from '@/lib/schemas';
|
||||
import { useDirectorAction } from './useDirectorAction';
|
||||
@@ -61,10 +62,42 @@ describe('useDirectorAction — approve-each apply bridge', () => {
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.rollRequests).toHaveLength(1);
|
||||
expect(r.rollRequests![0]).toMatchObject({ kind: 'save', dc: 10 }); // concentrationDC(10) = max(10, 5)
|
||||
// The linked sheet's CON save modifier rides along: Con 14, untrained → +2.
|
||||
expect(r.rollRequests![0]!.expression).toBe('1d20+2');
|
||||
const enc = await encountersRepo.get('e1');
|
||||
expect(enc!.combatants.find((c) => c.id === 'cb-mira')!.hp.current).toBe(10);
|
||||
});
|
||||
|
||||
it('derives the concentration DC from damage TAKEN and skips the save when fully resisted', async () => {
|
||||
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-mira', {
|
||||
damageDefenses: { resist: ['slashing'], immune: ['fire'], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [] },
|
||||
}));
|
||||
// 30 slashing halves to 15 taken → DC max(10, 7) = 10, NOT concentrationDC(30) = 15.
|
||||
const half = await hook().current({ kind: 'damage', target: 'Mira', amount: 30, damageType: 'slashing' });
|
||||
expect(half.rollRequests).toHaveLength(1);
|
||||
expect(half.rollRequests![0]!.dc).toBe(10);
|
||||
// Immune → zero damage taken → no save is due at all.
|
||||
const none = await hook().current({ kind: 'damage', target: 'Mira', amount: 10, damageType: 'fire' });
|
||||
expect(none.rollRequests).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the linked character's concentration when the combatant flag is unset", async () => {
|
||||
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-mira', { concentrating: null }));
|
||||
const conc = { ...characters[0]!, concentration: { spellId: 'sp1', spellName: 'Bless' } };
|
||||
const r = renderHook(() => useDirectorAction({ system: '5e', encounterId: 'e1', roster, characters: [conc] })).result;
|
||||
const res = await r.current({ kind: 'damage', target: 'Mira', amount: 8 });
|
||||
expect(res.rollRequests).toHaveLength(1);
|
||||
expect(res.rollRequests![0]!.label).toContain('Bless');
|
||||
});
|
||||
|
||||
it('uses a flat d20 with a note when no sheet is linked to the concentrating combatant', async () => {
|
||||
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-gob', { concentrating: 'Invisibility' }));
|
||||
const r = await hook().current({ kind: 'damage', target: 'Goblin', amount: 4 });
|
||||
expect(r.rollRequests).toHaveLength(1);
|
||||
expect(r.rollRequests![0]!.expression).toBe('1d20');
|
||||
expect(r.rollRequests![0]!.label).toContain('CON save bonus');
|
||||
});
|
||||
|
||||
it('rejects an action targeting an entity not on the board', async () => {
|
||||
const r = await hook().current({ kind: 'damage', target: 'Smaug', amount: 99 });
|
||||
expect(r.ok).toBe(false);
|
||||
@@ -109,4 +142,55 @@ describe('useDirectorAction — approve-each apply bridge', () => {
|
||||
const c = await charactersRepo.get('ch1');
|
||||
expect(c!.resources[0]!.current).toBe(0);
|
||||
});
|
||||
|
||||
it('caps healing at the effective max (5e exhaustion 4 halves it), like the tracker', async () => {
|
||||
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-gob', {
|
||||
hp: { current: 1, max: 7, temp: 0 },
|
||||
conditions: [{ name: 'Exhaustion', value: 4 }],
|
||||
}));
|
||||
const r = await hook().current({ kind: 'heal', target: 'Goblin', amount: 20 });
|
||||
expect(r.ok).toBe(true);
|
||||
const enc = await encountersRepo.get('e1');
|
||||
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.hp.current).toBe(4); // 7 − floor(7/2)
|
||||
});
|
||||
|
||||
it('pf2e: healing a dying combatant above 0 does the wake bookkeeping', async () => {
|
||||
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-mira', {
|
||||
hp: { current: 0, max: 20, temp: 0 },
|
||||
conditions: [{ name: 'Dying', value: 2 }, { name: 'Unconscious' }],
|
||||
}));
|
||||
const r = renderHook(() => useDirectorAction({ system: 'pf2e', encounterId: 'e1', roster, characters })).result;
|
||||
const res = await r.current({ kind: 'heal', target: 'Mira', amount: 5 });
|
||||
expect(res.ok).toBe(true);
|
||||
const mira = (await encountersRepo.get('e1'))!.combatants.find((c) => c.id === 'cb-mira')!;
|
||||
expect(mira.hp.current).toBe(5);
|
||||
expect(mira.conditions).toEqual([]); // Dying + Unconscious cleared, as the tracker does
|
||||
});
|
||||
|
||||
it('writes combat-log entries for damage and heal (parity with the tracker)', async () => {
|
||||
await hook().current({ kind: 'damage', target: 'Goblin', amount: 3 });
|
||||
await hook().current({ kind: 'heal', target: 'Goblin', amount: 2 });
|
||||
const texts = ((await encountersRepo.get('e1'))!.log ?? []).map((l) => l.text);
|
||||
expect(texts.some((t) => t.includes('Goblin takes 3 damage'))).toBe(true);
|
||||
expect(texts.some((t) => t.includes('Goblin heals 2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('addCombatant clones a same-named monster on the board — initiative 0, never rolled', async () => {
|
||||
const r = await hook().current({ kind: 'addCombatant', name: 'goblin' }); // case-insensitive
|
||||
expect(r.ok).toBe(true);
|
||||
const enc = await encountersRepo.get('e1');
|
||||
expect(enc!.combatants).toHaveLength(3);
|
||||
const added = enc!.combatants.find((c) => c.name === 'Goblin 2')!;
|
||||
expect(added.initiative).toBe(0);
|
||||
expect(added.hp).toEqual({ current: 7, max: 7, temp: 0 });
|
||||
});
|
||||
|
||||
it('addCombatant pulls stats from the bestiary for a monster not on the board', async () => {
|
||||
const r = await hook().current({ kind: 'addCombatant', name: 'Wolf' });
|
||||
expect(r.ok).toBe(true);
|
||||
const wolf = (await encountersRepo.get('e1'))!.combatants.find((c) => c.name === 'Wolf')!;
|
||||
expect(wolf.kind).toBe('monster');
|
||||
expect(wolf.initiative).toBe(0); // GM rolls initiative — the app never does
|
||||
expect(wolf.hp.max).toBeGreaterThan(1); // real SRD stats, not the 1-HP stub
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import { getSystem, type AbilityKey, type ProficiencyRank, type SystemId } from '@/lib/rules';
|
||||
import type { Character, Combatant } from '@/lib/schemas';
|
||||
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
applyDamage,
|
||||
damageOutcome,
|
||||
applyHealing,
|
||||
setTempHp,
|
||||
updateCombatant,
|
||||
addCombatant,
|
||||
nextTurn,
|
||||
logEvent,
|
||||
isMassiveDamageDeath,
|
||||
} from '@/lib/combat/engine';
|
||||
import { castSpell, spendResource, concentrationDC } from '@/lib/mechanics';
|
||||
import { castSpell, spendResource, concentrationDC, deriveEffectiveMaxHp } from '@/lib/mechanics';
|
||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { baseName } from '@/lib/assistant/encounter';
|
||||
import {
|
||||
resolveCombatant,
|
||||
resolveActor,
|
||||
@@ -30,8 +34,52 @@ export interface ApplyResult {
|
||||
rollRequests?: RollRequest[];
|
||||
}
|
||||
|
||||
function concentrationSave(name: string, spellName: string, damage: number): RollRequest {
|
||||
return { id: newId(), actor: name, label: `Concentration save (${spellName})`, kind: 'save', expression: '1d20', dc: concentrationDC(damage) };
|
||||
const norm = (s: string): string => s.trim().toLowerCase();
|
||||
|
||||
/** The 5e Constitution save modifier from a linked sheet, so the surfaced
|
||||
* concentration save carries the character's real bonus. */
|
||||
function conSaveMod(c: Character): number {
|
||||
return getSystem(c.system).saveModifiers({
|
||||
level: c.level,
|
||||
abilities: c.abilities,
|
||||
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
||||
}).con;
|
||||
}
|
||||
|
||||
/** DC derives from damage TAKEN (post-mitigation), per the 5e rule. When no sheet
|
||||
* is linked the expression stays a flat d20 and the label says what to add. */
|
||||
function concentrationSave(name: string, spellName: string, damageTaken: number, saveMod?: number): RollRequest {
|
||||
return {
|
||||
id: newId(),
|
||||
actor: name,
|
||||
label: saveMod === undefined ? `Concentration save (${spellName}) — add their CON save bonus` : `Concentration save (${spellName})`,
|
||||
kind: 'save',
|
||||
expression: saveMod === undefined ? '1d20' : `1d20${formatModifier(saveMod)}`,
|
||||
dc: concentrationDC(damageTaken),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a combatant from a bestiary entry WITHOUT rolling — initiative stays 0
|
||||
* until a human rolls it (the tracker's "Roll all" or the row's reroll). */
|
||||
function bestiaryCombatant(system: SystemId, m: Record<string, unknown>): Combatant {
|
||||
const is5e = system === '5e';
|
||||
const hp = Number(is5e ? m.hit_points : m.hp) || 1;
|
||||
const cr = Number(m.cr);
|
||||
const level = Number(m.level);
|
||||
return {
|
||||
id: newId(),
|
||||
name: String(m.name),
|
||||
kind: 'monster',
|
||||
initiative: 0,
|
||||
initBonus: is5e ? Math.floor(((Number(m.dexterity) || 10) - 10) / 2) : Number(m.perception) || 0,
|
||||
ac: Number(is5e ? m.armor_class : m.ac) || 10,
|
||||
hp: { current: hp, max: hp, temp: 0 },
|
||||
conditions: [],
|
||||
notes: '',
|
||||
...(typeof m.slug === 'string' ? { monsterRef: m.slug } : {}),
|
||||
...(is5e && Number.isFinite(cr) ? { cr } : {}),
|
||||
...(!is5e && Number.isFinite(level) ? { level } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +104,15 @@ export function useDirectorAction(opts: {
|
||||
const actor = resolveActor(roster, name);
|
||||
return actor?.characterId ? characters.find((c) => c.id === actor.characterId) : undefined;
|
||||
};
|
||||
/** Effective max HP — drained / exhaustion 4 reduce it (same as the tracker). */
|
||||
const effMaxOf = (c: Combatant): number =>
|
||||
deriveEffectiveMaxHp({
|
||||
system,
|
||||
baseMaxHp: c.hp.max,
|
||||
level: c.level ?? 1,
|
||||
exhaustion: c.conditions.find((x) => norm(x.name) === 'exhaustion')?.value ?? 0,
|
||||
conditions: c.conditions,
|
||||
}).max;
|
||||
|
||||
switch (action.kind) {
|
||||
case 'damage': {
|
||||
@@ -65,14 +122,36 @@ export function useDirectorAction(opts: {
|
||||
await encountersRepo.mutate(encounterId, (e) => {
|
||||
const c = resolveCombatant(e, action.target);
|
||||
if (!c) return e;
|
||||
const after = applyDamage(c, action.amount, action.damageType);
|
||||
const massive = isMassiveDamageDeath(c.hp.max, after.hp.current);
|
||||
const { after, dealt, overflow } = damageOutcome(c, action.amount, action.damageType);
|
||||
// Same rules bookkeeping as the tracker row: 5e massive damage against the
|
||||
// EFFECTIVE max, death-state reminders surfaced (never applied), a log trail.
|
||||
const massive = system === '5e' && isMassiveDamageDeath(effMaxOf(c), overflow);
|
||||
const typeLabel = action.damageType ? ` ${action.damageType}` : '';
|
||||
const note = dealt === action.amount
|
||||
? `${c.name} takes ${dealt}${typeLabel} damage`
|
||||
: `${c.name} takes ${dealt}${typeLabel} damage (${action.amount} before ${dealt < action.amount ? 'resistance' : 'vulnerability'})`;
|
||||
let reminder: string | null = null;
|
||||
if (c.kind !== 'monster' && dealt > 0) {
|
||||
if (system === 'pf2e') {
|
||||
if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`;
|
||||
else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`;
|
||||
} else if (massive) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`;
|
||||
else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`;
|
||||
}
|
||||
out = {
|
||||
ok: true,
|
||||
message: `${c.name}: ${action.amount}${action.damageType ? ` ${action.damageType}` : ''} damage → ${after.hp.current}/${after.hp.max} HP${massive ? ' (massive damage — instant death!)' : ''}`,
|
||||
message: `${c.name}: ${dealt}${typeLabel} damage → ${after.hp.current}/${after.hp.max} HP${massive ? ' (massive damage — instant death!)' : ''}`,
|
||||
};
|
||||
if (system === '5e' && c.concentrating) followUps.push(concentrationSave(c.name, c.concentrating, action.amount));
|
||||
return updateCombatant(e, c.id, { hp: after.hp });
|
||||
// 5e: damage TAKEN forces the Con save — a fully-resisted hit forces nothing.
|
||||
// The combatant flag OR the linked character's concentration (set on cast) counts.
|
||||
if (system === '5e' && dealt > 0) {
|
||||
const pc = pcFor(c.name);
|
||||
const spellName = c.concentrating || pc?.concentration?.spellName;
|
||||
if (spellName) followUps.push(concentrationSave(c.name, spellName, dealt, pc ? conSaveMod(pc) : undefined));
|
||||
}
|
||||
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
||||
if (reminder) next = logEvent(next, reminder);
|
||||
return next;
|
||||
});
|
||||
return followUps.length ? { ...out, rollRequests: followUps } : out;
|
||||
}
|
||||
@@ -83,9 +162,19 @@ export function useDirectorAction(opts: {
|
||||
await encountersRepo.mutate(encounterId, (e) => {
|
||||
const c = resolveCombatant(e, action.target);
|
||||
if (!c) return e;
|
||||
const after = applyHealing(c, action.amount);
|
||||
// Cap at the effective max, and do the tracker's wake bookkeeping: pf2e
|
||||
// healing above 0 ends Dying/unconscious; 5e reminds to reset death saves.
|
||||
const after = applyHealing(c, action.amount, effMaxOf(c));
|
||||
const rose = c.hp.current <= 0 && after.hp.current > 0;
|
||||
const woke = system === 'pf2e' && rose;
|
||||
const conditions = woke
|
||||
? c.conditions.filter((x) => !['unconscious', 'dying'].includes(norm(x.name)))
|
||||
: c.conditions;
|
||||
out = { ok: true, message: `${c.name} healed ${action.amount} → ${after.hp.current}/${after.hp.max} HP` };
|
||||
return updateCombatant(e, c.id, { hp: after.hp });
|
||||
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${action.amount}`);
|
||||
if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`);
|
||||
else if (rose && c.kind !== 'monster') next = logEvent(next, `${c.name} is back up — they wake; reset death saves on their sheet.`);
|
||||
return next;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -159,8 +248,38 @@ export function useDirectorAction(opts: {
|
||||
return { ok: true, message: r.log.join(' ') || `${c.name} spent ${action.amount} ${res.name}.` };
|
||||
}
|
||||
|
||||
case 'addCombatant':
|
||||
return { ok: false, message: 'Adding monsters from the director arrives in a later update.' };
|
||||
case 'addCombatant': {
|
||||
if (!encounterId) return noCombat;
|
||||
const want = action.name.trim();
|
||||
// Bestiary loaders are module-cached, so this is cheap after the first use.
|
||||
let entry: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const pool = (system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record<string, unknown>[];
|
||||
entry =
|
||||
pool.find((m) => norm(String(m.name)) === norm(want)) ??
|
||||
(action.monsterRef ? pool.find((m) => norm(String(m.slug ?? m.name)) === norm(action.monsterRef!)) : undefined);
|
||||
} catch {
|
||||
entry = undefined; // offline — fall back to cloning or a stub
|
||||
}
|
||||
let addedName = want;
|
||||
await encountersRepo.mutate(encounterId, (e) => {
|
||||
// Prefer cloning a same-named monster already in the fight (covers homebrew).
|
||||
const template = e.combatants.find((c) => c.kind === 'monster' && norm(baseName(c.name)) === norm(want));
|
||||
const fresh: Combatant = template
|
||||
? { ...template, id: newId(), name: baseName(template.name), initiative: 0, hp: { current: template.hp.max, max: template.hp.max, temp: 0 }, conditions: [], concentrating: null }
|
||||
: entry
|
||||
? bestiaryCombatant(system, entry)
|
||||
: {
|
||||
id: newId(), name: want, kind: 'monster', initiative: 0, initBonus: 0, ac: 10,
|
||||
hp: { current: 1, max: 1, temp: 0 }, conditions: [],
|
||||
notes: 'Added by the director — set HP/AC from its stat block.',
|
||||
};
|
||||
const next = addCombatant(e, fresh); // auto-numbers duplicates, keeps the turn anchored
|
||||
addedName = next.combatants.find((c) => c.id === fresh.id)?.name ?? fresh.name;
|
||||
return logEvent(next, `${addedName} joins the fight`);
|
||||
});
|
||||
return { ok: true, message: `Added ${addedName} to the encounter — roll their initiative.` };
|
||||
}
|
||||
}
|
||||
},
|
||||
[system, encounterId, roster, characters],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { aiSessionsRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
@@ -6,6 +6,9 @@ import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||
import { pickCreatureCandidates } from '@/lib/assistant/context';
|
||||
import { baseName } from '@/lib/assistant/encounter';
|
||||
import {
|
||||
buildDirectorScene,
|
||||
runDirectorTurn,
|
||||
@@ -24,6 +27,10 @@ import { useDirectorAction, type ApplyResult } from './useDirectorAction';
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
/** Hard cap from directorTranscriptEntrySchema (`text` max 8000) — truncate rather
|
||||
* than let the repo's schema parse throw and abort the whole turn. */
|
||||
const MAX_ENTRY_TEXT = 8000;
|
||||
|
||||
/** When hosting a live session and sharing is on, push the director's narration to
|
||||
* the players' screens via the table chat channel. A no-op when not hosting. */
|
||||
function broadcastNarration(text: string): void {
|
||||
@@ -51,6 +58,11 @@ async function maybeSummarize(sessionId: string, systemConstraint: string, windo
|
||||
const res = await complete(cfg, { system, user, maxTokens: 512 });
|
||||
if (res.ok && 'text' in res && res.text.trim()) summary = res.text.trim();
|
||||
}
|
||||
// The LLM call above can take many seconds. If the session was restarted (or
|
||||
// otherwise re-summarized) meanwhile, this plan describes a transcript that no
|
||||
// longer exists — writing it would hide the fresh session's turns from the model.
|
||||
const latest = await aiSessionsRepo.get(sessionId);
|
||||
if (!latest || latest.summarizedThrough !== s.summarizedThrough || latest.transcript.length < plan.newSummarizedThrough) return;
|
||||
await aiSessionsRepo.setSummary(sessionId, summary, plan.newSummarizedThrough);
|
||||
}
|
||||
|
||||
@@ -62,6 +74,8 @@ const DEGREE_LABEL: Record<Degree, string> = {
|
||||
};
|
||||
|
||||
export interface LatestTurn {
|
||||
/** stable per-turn identity — keys the cards so applied state can't leak across turns */
|
||||
id: string;
|
||||
rollRequests: RollRequest[];
|
||||
actions: DirectorAction[];
|
||||
suggestions: string[];
|
||||
@@ -76,14 +90,24 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
|
||||
const windowSize = useDirectorStore((s) => s.windowSize);
|
||||
const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey);
|
||||
// The AI seat only applies in the player persona.
|
||||
const controlledCharacterId = persona === 'player' ? controlledId || undefined : undefined;
|
||||
|
||||
const session = useLatestAiSession(campaign.id, persona);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const activeEnc = encounters.find((e) => e.status === 'active');
|
||||
|
||||
// The AI seat only applies in the player persona. The persisted id is app-global,
|
||||
// so a seat picked in another campaign must not silently pilot a nonexistent PC —
|
||||
// only honor it when it matches a character of THIS campaign.
|
||||
const seatValid = characters.some((c) => c.id === controlledId);
|
||||
const controlledCharacterId = persona === 'player' && seatValid ? controlledId : undefined;
|
||||
|
||||
// A just-created session id, held until the live query catches up — recordRoll and
|
||||
// applyAction must not drop entries (and run must not double-create) in that window.
|
||||
const createdSid = useRef<{ key: string; id: string } | null>(null);
|
||||
const sessionKey = `${campaign.id}:${persona}`;
|
||||
const sessionId = session?.id ?? (createdSid.current?.key === sessionKey ? createdSid.current.id : undefined);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [source, setSource] = useState<'llm' | 'fallback' | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -101,11 +125,29 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
let sid = session?.id;
|
||||
if (!sid) sid = (await aiSessionsRepo.create(campaign.id, persona)).id;
|
||||
let sid = sessionId;
|
||||
if (!sid) {
|
||||
sid = (await aiSessionsRepo.create(campaign.id, persona)).id;
|
||||
createdSid.current = { key: sessionKey, id: sid };
|
||||
}
|
||||
|
||||
if (input && input.trim()) {
|
||||
await aiSessionsRepo.appendEntries(sid, [{ id: newId(), role: 'player-input', text: input.trim(), ts: nowIso() }]);
|
||||
await aiSessionsRepo.appendEntries(sid, [{ id: newId(), role: 'player-input', text: input.trim().slice(0, MAX_ENTRY_TEXT), ts: nowIso() }]);
|
||||
}
|
||||
|
||||
// Grounded monster names the DM persona may ADD to a live fight — what's
|
||||
// already on the board plus level-appropriate bestiary picks.
|
||||
let addCandidates: string[] = [];
|
||||
if (persona === 'dm' && activeEnc) {
|
||||
try {
|
||||
const raw = (campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record<string, unknown>[];
|
||||
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||
const existing = activeEnc.combatants.filter((c) => c.kind === 'monster').map((c) => baseName(c.name));
|
||||
const picks = pickCreatureCandidates(campaign.system, partyLevels, raw, 'moderate', 8).map((c) => c.name);
|
||||
addCandidates = [...new Set([...existing, ...picks])];
|
||||
} catch {
|
||||
// offline / data failed to load — the director simply can't add monsters this turn
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = await aiSessionsRepo.get(sid);
|
||||
@@ -119,6 +161,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
narrationStyle,
|
||||
transcriptWindow: plan.window,
|
||||
summary: fresh?.summary,
|
||||
addCandidates,
|
||||
});
|
||||
|
||||
const res = await runDirectorTurn(getLlmConfig(), scene);
|
||||
@@ -129,42 +172,45 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
await aiSessionsRepo.appendEntries(sid, [{
|
||||
id: newId(),
|
||||
role: 'narration',
|
||||
...(res.turn.speaker ? { speaker: res.turn.speaker } : {}),
|
||||
text: res.turn.narration,
|
||||
...(res.turn.speaker ? { speaker: res.turn.speaker.slice(0, 120) } : {}),
|
||||
text: res.turn.narration.slice(0, MAX_ENTRY_TEXT),
|
||||
ts: nowIso(),
|
||||
}]);
|
||||
broadcastNarration(res.turn.speaker ? `${res.turn.speaker}: ${res.turn.narration}` : res.turn.narration);
|
||||
}
|
||||
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
|
||||
setLastTurn({ id: newId(), rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
|
||||
|
||||
// Token budget: fold older turns into a rolling summary so the context stays bounded.
|
||||
await maybeSummarize(sid, scene.systemConstraint, windowSize);
|
||||
} catch {
|
||||
setMessage('Something went wrong running the turn — try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session?.id, campaign, persona, controlledCharacterId, characters, activeEnc, narrationStyle, windowSize],
|
||||
[sessionId, sessionKey, campaign, persona, controlledCharacterId, characters, activeEnc, narrationStyle, windowSize],
|
||||
);
|
||||
|
||||
const recordRoll = useCallback(
|
||||
async (req: RollRequest, result: { total: number; degree?: Degree }) => {
|
||||
if (!session) return;
|
||||
if (!sessionId) return;
|
||||
const dc = req.dc !== undefined ? ` (DC ${req.dc})` : '';
|
||||
const deg = result.degree ? ` — ${DEGREE_LABEL[result.degree]}` : '';
|
||||
const text = `${req.label}${dc}: ${result.total}${deg}`;
|
||||
await aiSessionsRepo.appendEntries(session.id, [{
|
||||
id: newId(), role: 'roll-result', ...(req.actor ? { speaker: req.actor } : {}), text, ts: nowIso(),
|
||||
const text = `${req.label}${dc}: ${result.total}${deg}`.slice(0, MAX_ENTRY_TEXT);
|
||||
await aiSessionsRepo.appendEntries(sessionId, [{
|
||||
id: newId(), role: 'roll-result', ...(req.actor ? { speaker: req.actor.slice(0, 120) } : {}), text, ts: nowIso(),
|
||||
}]);
|
||||
setLastTurn((prev) => (prev ? { ...prev, rollRequests: prev.rollRequests.filter((r) => r.id !== req.id) } : prev));
|
||||
// The card stays mounted — RollRequestCard swaps its Roll button for the
|
||||
// result badge; the next turn's setLastTurn replaces the list wholesale.
|
||||
},
|
||||
[session],
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const applyAction = useCallback(
|
||||
async (action: DirectorAction): Promise<ApplyResult> => {
|
||||
const r = await apply(action);
|
||||
if (r.ok && session) {
|
||||
await aiSessionsRepo.appendEntries(session.id, [{ id: newId(), role: 'system', text: `Applied: ${r.message}`, ts: nowIso() }]);
|
||||
if (r.ok && sessionId) {
|
||||
await aiSessionsRepo.appendEntries(sessionId, [{ id: newId(), role: 'system', text: `Applied: ${r.message}`.slice(0, MAX_ENTRY_TEXT), ts: nowIso() }]);
|
||||
}
|
||||
if (r.rollRequests?.length) {
|
||||
setLastTurn((prev) => (prev ? { ...prev, rollRequests: [...prev.rollRequests, ...r.rollRequests!] } : prev));
|
||||
@@ -172,7 +218,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
if (!r.ok) setMessage(r.message);
|
||||
return r;
|
||||
},
|
||||
[apply, session],
|
||||
[apply, sessionId],
|
||||
);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useMemo, useRef, useState } from 'react';
|
||||
import type { Campaign, 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 { addCombatant, removeCombatant } from '@/lib/combat/engine';
|
||||
import { computeBudget } from '@/lib/combat/budget';
|
||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||
@@ -23,6 +21,8 @@ export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
const ORDINAL: Record<string, number> = {
|
||||
trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4,
|
||||
};
|
||||
|
||||
const norm = (s: string): string => s.trim().toLowerCase();
|
||||
const TIER_LABEL: Record<Campaign['system'], Record<number, string>> = {
|
||||
'5e': { 2: 'Medium', 3: 'Hard', 4: 'Deadly' },
|
||||
pf2e: { 2: 'Moderate', 3: 'Severe', 4: 'Extreme' },
|
||||
@@ -45,8 +45,10 @@ function toCombatant(system: Campaign['system'], m: Record<string, unknown>) {
|
||||
const level = Number(m.level);
|
||||
return {
|
||||
id: newId(), name, kind: 'monster' as const,
|
||||
initiative: rollDice('1d20', createRng()).total + initBonus, initBonus,
|
||||
// NO auto-roll: initiative stays 0 until a human rolls it (tracker "Roll all").
|
||||
initiative: 0, initBonus,
|
||||
ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '',
|
||||
...(typeof m.slug === 'string' ? { monsterRef: m.slug } : {}),
|
||||
...(is5e && Number.isFinite(cr) ? { cr } : {}),
|
||||
...(!is5e && Number.isFinite(level) ? { level } : {}),
|
||||
};
|
||||
@@ -134,7 +136,12 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
||||
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
|
||||
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
|
||||
if (res.ok && 'data' in res) {
|
||||
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
|
||||
// Resolve names case-insensitively (the provider mangles casing) and
|
||||
// canonicalize to the candidate's exact name so apply() can find it.
|
||||
const valid = res.data.add.flatMap((a) => {
|
||||
const cand = candidates.find((c) => norm(c.name) === norm(a.name));
|
||||
return cand ? [{ ...a, name: cand.name }] : [];
|
||||
});
|
||||
if (valid.length) {
|
||||
setSuggestion({ ...res.data, add: valid });
|
||||
setSource('llm');
|
||||
@@ -171,7 +178,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
||||
for (const r of suggestion.remove ?? []) {
|
||||
for (let i = 0; i < r.count; i++) {
|
||||
const victim = [...next.combatants].reverse().find(
|
||||
(c) => c.kind === 'monster' && baseName(c.name) === r.name,
|
||||
(c) => c.kind === 'monster' && norm(baseName(c.name)) === norm(r.name),
|
||||
);
|
||||
if (!victim) break;
|
||||
next = removeCombatant(next, victim.id);
|
||||
@@ -180,15 +187,17 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
||||
for (const a of suggestion.add) {
|
||||
// Prefer cloning a creature already in the fight (handles "add another goblin"
|
||||
// and works even for custom/homebrew combatants); otherwise pull from the pool.
|
||||
const template = e.combatants.find((c) => c.kind === 'monster' && baseName(c.name) === a.name);
|
||||
// Names resolve case-insensitively, like every other assistant resolver.
|
||||
const template = e.combatants.find((c) => c.kind === 'monster' && norm(baseName(c.name)) === norm(a.name));
|
||||
const make = template
|
||||
? () => ({
|
||||
...template, id: newId(), name: baseName(template.name),
|
||||
hp: { current: template.hp.max, max: template.hp.max, temp: 0 },
|
||||
conditions: [], initiative: rollDice('1d20', createRng()).total + template.initBonus,
|
||||
// NO auto-roll: the clone joins unrolled; the GM's "Roll all" covers it.
|
||||
conditions: [], initiative: 0,
|
||||
})
|
||||
: (() => {
|
||||
const m = poolRef.current.find((p) => String(p.name) === a.name);
|
||||
const m = poolRef.current.find((p) => norm(String(p.name)) === norm(a.name));
|
||||
return m ? () => toCombatant(campaign.system, m) : null;
|
||||
})();
|
||||
if (!make) continue;
|
||||
|
||||
@@ -223,10 +223,15 @@ export function Wizard5eAbilityTable({
|
||||
const total = base + race + asi;
|
||||
const mod = abilityModifier(total);
|
||||
const isKey = keyAbilities?.includes(ability);
|
||||
// Out-of-range carry-ins (scores set in manual mode) can't be priced.
|
||||
const pbOut = method === 'pointbuy' && (pb[i]! < POINT_BUY_MIN || pb[i]! > POINT_BUY_MAX);
|
||||
const pbMax = method === 'pointbuy'
|
||||
? (() => {
|
||||
let best = pb[i]!;
|
||||
for (let c = pb[i]! + 1; c <= POINT_BUY_MAX; c++) {
|
||||
// Start from the clamped value so an out-of-range score can always
|
||||
// be edited back into the legal 8–15 window (a sub-8 score must not
|
||||
// pin the field to min=8/max=<8).
|
||||
let best = Math.min(POINT_BUY_MAX, Math.max(POINT_BUY_MIN, pb[i]!));
|
||||
for (let c = best + 1; c <= POINT_BUY_MAX; c++) {
|
||||
if (pointBuyRemaining(pb.map((x, j) => (j === i ? c : x))) >= 0) best = c; else break;
|
||||
}
|
||||
return best;
|
||||
@@ -263,14 +268,17 @@ export function Wizard5eAbilityTable({
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<NumberField
|
||||
className="w-16"
|
||||
value={pb[i]!}
|
||||
min={method === 'pointbuy' ? POINT_BUY_MIN : 1}
|
||||
max={pbMax}
|
||||
aria-label={`${ABILITY_ABBR[ability]} base score`}
|
||||
onChange={(v) => onChangePb(i, v)}
|
||||
/>
|
||||
<>
|
||||
<NumberField
|
||||
className="w-16"
|
||||
value={pb[i]!}
|
||||
min={method === 'pointbuy' ? POINT_BUY_MIN : 1}
|
||||
max={pbMax}
|
||||
aria-label={`${ABILITY_ABBR[ability]} base score`}
|
||||
onChange={(v) => onChangePb(i, v)}
|
||||
/>
|
||||
{pbOut && <div className="mt-0.5 text-[9px] text-danger">out of 8–15</div>}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -335,8 +343,11 @@ export function Wizard5eAbilityTable({
|
||||
<span className="text-xs text-muted">
|
||||
Point buy remaining:{' '}
|
||||
<span className={cn('font-semibold', pbRemaining < 0 ? 'text-danger' : 'text-ink')}>
|
||||
{pbRemaining} / 27
|
||||
{Number.isFinite(pbRemaining) ? pbRemaining : '—'} / 27
|
||||
</span>
|
||||
{!Number.isFinite(pbRemaining) && (
|
||||
<span className="ml-2 text-danger">bring the marked scores into 8–15 first</span>
|
||||
)}
|
||||
<span className="ml-2 text-muted/60">(scores 8–15, standard array: 15 14 13 12 10 8)</span>
|
||||
</span>
|
||||
</td>
|
||||
@@ -412,6 +423,25 @@ export function WizardPf2eAbilityTable({
|
||||
const hasFlaw = !!flaw;
|
||||
const hasClassBoost = !!classBoost;
|
||||
|
||||
// Realized per-boost deltas: replay the boosts in pf2eApplyBoosts order (+2 while
|
||||
// the running score is below 18, else +1) so each cell shows its true contribution
|
||||
// and the per-source breakdown always sums to the Total column.
|
||||
const running: Record<AbilityKey, number> = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||
if (flaw) running[flaw] -= 2;
|
||||
const applyBoost = (a: AbilityKey): number => {
|
||||
const d = running[a] < 18 ? 2 : 1;
|
||||
running[a] += d;
|
||||
return d;
|
||||
};
|
||||
const fixedDelta: Partial<Record<AbilityKey, number>> = {};
|
||||
for (const a of fixed) fixedDelta[a] = (fixedDelta[a] ?? 0) + applyBoost(a);
|
||||
const classDelta = classBoost ? applyBoost(classBoost) : 0;
|
||||
const slotDelta: Record<string, number> = {};
|
||||
for (const s of slots) {
|
||||
const p = picks[s.id];
|
||||
if (p) slotDelta[s.id] = applyBoost(p);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
@@ -457,7 +487,7 @@ export function WizardPf2eAbilityTable({
|
||||
{fixed.length > 0 && (
|
||||
<td className={TD}>
|
||||
{fixedSet.has(ability)
|
||||
? <DeltaBadge delta={2} kind="boost" />
|
||||
? <DeltaBadge delta={fixedDelta[ability] ?? 2} kind="boost" />
|
||||
: <span className="text-muted/20">—</span>}
|
||||
</td>
|
||||
)}
|
||||
@@ -475,7 +505,7 @@ export function WizardPf2eAbilityTable({
|
||||
{hasClassBoost && (
|
||||
<td className={TD}>
|
||||
{classBoost === ability
|
||||
? <DeltaBadge delta={2} kind="boost" />
|
||||
? <DeltaBadge delta={classDelta || 2} kind="boost" />
|
||||
: <span className="text-muted/20">—</span>}
|
||||
</td>
|
||||
)}
|
||||
@@ -522,7 +552,7 @@ export function WizardPf2eAbilityTable({
|
||||
: 'text-muted/20 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{pickedHere ? '+2' : '·'}
|
||||
{pickedHere ? `+${slotDelta[s.id] ?? 2}` : '·'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted/20 text-xs">—</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { Character, EquippedArmor } from '@/lib/schemas';
|
||||
import { primaryClass } from '@/lib/schemas';
|
||||
import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
|
||||
import { getSystem, getClassDef, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
|
||||
import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
|
||||
import { loadPf2e } from '@/lib/compendium';
|
||||
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
||||
import { encodeClaim } from '@/lib/sync/playerLink';
|
||||
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||
@@ -41,6 +43,12 @@ const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master
|
||||
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);
|
||||
// Top-level fields the user edited in THIS sheet session. Saves persist ONLY these,
|
||||
// and fresh rows from concurrent writers (player sync, compendium adds, director
|
||||
// rests) are merged into every other field — so the sheet neither clobbers nor
|
||||
// hides changes made while it is open.
|
||||
const dirty = useRef<Set<keyof Character>>(new Set());
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const campaigns = useCampaigns();
|
||||
const [levelUp, setLevelUp] = useState(false);
|
||||
const [genScores, setGenScores] = useState(false);
|
||||
@@ -81,12 +89,24 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
setShareLink(link);
|
||||
};
|
||||
const save = useDebouncedCallback((next: Character) => {
|
||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
|
||||
void charactersRepo.update(id, rest);
|
||||
// Persist only the fields edited on this sheet (never identity/timestamps —
|
||||
// update() stamps updatedAt). campaignId IS editable via the Campaign selector.
|
||||
const patch: Partial<Character> = {};
|
||||
for (const key of dirty.current) {
|
||||
if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue;
|
||||
(patch as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
charactersRepo.update(next.id, patch).then(
|
||||
() => setSaveError(null),
|
||||
// A rejected IndexedDB write (quota, closed DB) must not pass silently —
|
||||
// the optimistic local copy would look saved while being lost on reload.
|
||||
(err: unknown) => setSaveError(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}, 350);
|
||||
|
||||
const update = (patch: Partial<Character>) => {
|
||||
for (const key of Object.keys(patch)) dirty.current.add(key as keyof Character);
|
||||
setC((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
save(next);
|
||||
@@ -94,6 +114,16 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
});
|
||||
};
|
||||
|
||||
// Adopt concurrent updates from the live query: take the fresh row, keeping local
|
||||
// values only for fields edited here. Our own write-backs pass through as no-ops.
|
||||
useEffect(() => {
|
||||
setC((prev) => {
|
||||
const merged = { ...character };
|
||||
for (const key of dirty.current) (merged as Record<string, unknown>)[key] = prev[key];
|
||||
return merged;
|
||||
});
|
||||
}, [character]);
|
||||
|
||||
const sys = getSystem(c.system);
|
||||
const rulesInput: CharacterRulesInput = {
|
||||
level: c.level,
|
||||
@@ -103,6 +133,8 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
armorBonus: c.armorBonus,
|
||||
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
|
||||
perceptionRank: c.perceptionRank,
|
||||
...(c.acRank ? { acRank: c.acRank } : {}),
|
||||
...(c.classDcRank ? { classDcRank: c.classDcRank } : {}),
|
||||
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
||||
};
|
||||
|
||||
@@ -123,6 +155,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
|
||||
const ac = sys.baseArmorClass(rulesInput);
|
||||
const initiative = sys.initiativeModifier(rulesInput);
|
||||
// PF2e Class DC: keyed to the class's key ability (fall back to STR for unknown classes).
|
||||
const classKeyAbility: AbilityKey = getClassDef(c.system, primaryClass(c))?.keyAbilities[0] ?? 'str';
|
||||
const classDc = sys.classDc?.(rulesInput, classKeyAbility);
|
||||
const skills = sys.skillModifiers(rulesInput);
|
||||
const saves = sys.saveModifiers(rulesInput);
|
||||
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
|
||||
@@ -137,6 +172,12 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div role="alert" className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
Saving failed — recent edits are not persisted ({saveError}). Free up storage, then edit any field to retry.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header hero */}
|
||||
<div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-4 p-5">
|
||||
@@ -199,11 +240,24 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* PF2e is single-class: keep classes[] (the source features/choices read)
|
||||
in step with the legacy className/level mirrors. */}
|
||||
<Labeled label="Class">
|
||||
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
|
||||
<Input
|
||||
value={c.className}
|
||||
onChange={(e) => {
|
||||
const className = e.target.value;
|
||||
update({ className, classes: className ? [{ ...(c.classes[0] ?? { level: c.level }), className }] : [] });
|
||||
}}
|
||||
/>
|
||||
</Labeled>
|
||||
<Labeled label="Level">
|
||||
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
|
||||
<NumberField
|
||||
value={c.level}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(level) => update({ level, classes: c.classes.length ? c.classes.map((e, i) => (i === 0 ? { ...e, level } : e)) : c.className ? [{ className: c.className, level }] : [] })}
|
||||
/>
|
||||
</Labeled>
|
||||
</>
|
||||
)}
|
||||
@@ -218,11 +272,19 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</Labeled>
|
||||
</div>
|
||||
|
||||
{/* Vital stats */}
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
{/* Vital stats (PF2e adds a Class DC card) */}
|
||||
<div className={cn('mb-6 grid gap-3', c.system === 'pf2e' ? 'sm:grid-cols-2 lg:grid-cols-4' : 'sm:grid-cols-3')}>
|
||||
<StatCard label="Armor Class" value={ac} hint={c.equippedArmor ? `${c.equippedArmor.name}${c.armorBonus ? ` + ${c.armorBonus}` : ''}` : `10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
|
||||
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
|
||||
{c.system === '5e' && <ArmorPicker c={c} update={update} />}
|
||||
{c.system === '5e' ? <ArmorPicker c={c} update={update} /> : <ArmorPickerPf2e c={c} update={update} />}
|
||||
{c.system === 'pf2e' && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span>Defense proficiency</span>
|
||||
<Select className="w-auto py-1 text-xs" value={c.acRank ?? 'trained'} onChange={(e) => update({ acRank: e.target.value as ProficiencyRank })} aria-label="Armor proficiency rank">
|
||||
{RANKS_PF2E.filter((r) => r !== 'untrained').map((r) => <option key={r} value={r}>{rankLabel(r, 'pf2e')}</option>)}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Shield/misc bonus</span>
|
||||
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
|
||||
@@ -232,6 +294,17 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
|
||||
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
|
||||
|
||||
{c.system === 'pf2e' && classDc !== undefined && (
|
||||
<StatCard label="Class DC" value={classDc} hint={`${ABILITY_ABBR[classKeyAbility]} · ${rankLabel(c.classDcRank ?? 'trained', 'pf2e')}`}>
|
||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
|
||||
<span>Proficiency</span>
|
||||
<Select className="w-auto py-1 text-xs" value={c.classDcRank ?? 'trained'} onChange={(e) => update({ classDcRank: e.target.value as ProficiencyRank })} aria-label="Class DC proficiency rank">
|
||||
{RANKS_PF2E.filter((r) => r !== 'untrained').map((r) => <option key={r} value={r}>{rankLabel(r, 'pf2e')}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
</StatCard>
|
||||
)}
|
||||
|
||||
<HpCard c={c} update={update} />
|
||||
</div>
|
||||
|
||||
@@ -447,7 +520,8 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
|
||||
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) } });
|
||||
// HP floors at 0 in both systems (dropping to 0 is death saves / dying territory).
|
||||
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: Math.max(0, c.hp.current - (delta - absorbed)) } });
|
||||
} else {
|
||||
// Heal caps at the EFFECTIVE max (drained / exhaustion 4 reduce it).
|
||||
const current = Math.min(effMax, c.hp.current + delta);
|
||||
@@ -458,6 +532,10 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
|
||||
if (c.defenses.dying > 0) patch.defenses = { ...c.defenses, dying: 0, wounded: c.defenses.wounded + 1 };
|
||||
patch.conditions = c.conditions.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
|
||||
}
|
||||
// 5e: regaining any HP resets death-save counts (PHB) — also clears stale pips.
|
||||
if (c.system === '5e' && current > c.hp.current && (c.defenses.deathSaves.successes > 0 || c.defenses.deathSaves.failures > 0)) {
|
||||
patch.defenses = { ...c.defenses, deathSaves: { successes: 0, failures: 0 } };
|
||||
}
|
||||
update(patch);
|
||||
}
|
||||
setDelta(0);
|
||||
@@ -579,6 +657,57 @@ function ArmorPicker({ c, update }: { c: Character; update: (p: Partial<Characte
|
||||
);
|
||||
}
|
||||
|
||||
/** PF2e body-armor picker: maps the AoN armor data onto equippedArmor — baseAc
|
||||
* embeds 10 + the armor's item bonus, matching what pf2e.baseArmorClass expects. */
|
||||
function ArmorPickerPf2e({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
||||
const [armors, setArmors] = useState<{ name: string; itemAc: number; dexCap: number | null; category: EquippedArmor['category'] }[]>([]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
void loadPf2e('armor').then((rows) => {
|
||||
if (!on) return;
|
||||
const mapped = rows
|
||||
.filter((r) => typeof r.name === 'string')
|
||||
.map((r) => {
|
||||
const cat = String(r.armor_category ?? '').toLowerCase();
|
||||
const category: EquippedArmor['category'] =
|
||||
cat === 'light' ? 'light' : cat === 'medium' ? 'medium' : cat === 'heavy' ? 'heavy' : 'unarmored';
|
||||
const dexRaw = Number(r.dex_cap);
|
||||
return {
|
||||
name: String(r.name),
|
||||
itemAc: Number(r.ac) || 0,
|
||||
dexCap: Number.isFinite(dexRaw) ? dexRaw : null,
|
||||
category,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.itemAc - b.itemAc || a.name.localeCompare(b.name));
|
||||
setArmors(mapped);
|
||||
}).catch(() => { /* picker stays empty; AC falls back to unarmored */ });
|
||||
return () => { on = false; };
|
||||
}, []);
|
||||
return (
|
||||
<Select
|
||||
className="w-auto py-1 text-xs"
|
||||
aria-label="Equipped armor"
|
||||
value={c.equippedArmor?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const found = armors.find((a) => a.name === e.target.value);
|
||||
update({
|
||||
equippedArmor: found
|
||||
? { name: found.name, category: found.category, baseAc: 10 + found.itemAc, dexCap: found.dexCap, stealthDisadvantage: false }
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Unarmored</option>
|
||||
{armors.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name} (+{a.itemAc}{a.dexCap !== null ? ` · Dex≤${a.dexCap}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { useCharacter } from './hooks';
|
||||
import { CharacterSheet } from './CharacterSheet';
|
||||
import { Page, EmptyState } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
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.
|
||||
// Still loading (the hook returns null for a definitive miss).
|
||||
return (
|
||||
<Page>
|
||||
<EmptyState title="Loading character…" />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
if (character === null) {
|
||||
return (
|
||||
<Page>
|
||||
<EmptyState
|
||||
title="Character not found"
|
||||
hint="It may have been deleted, or the link is stale."
|
||||
action={<Link to="/characters"><Button variant="secondary">Back to characters</Button></Link>}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
// Keyed so local sheet state resets cleanly when switching characters.
|
||||
return <CharacterSheet key={character.id} character={character} />;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
|
||||
const text = await pickTextFile();
|
||||
if (text === null) return;
|
||||
try {
|
||||
const character = parseCharacterImport(text, campaign?.id ?? '');
|
||||
const character = parseCharacterImport(text, campaign?.id ?? '', campaign?.system);
|
||||
await charactersRepo.insert(character);
|
||||
} catch (e) {
|
||||
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Lightbulb, RotateCcw } from 'lucide-react';
|
||||
import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||
getSystem, ABILITY_ABBR, ABILITY_LABELS, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
||||
bumpRank, collectChoices,
|
||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||
@@ -17,7 +17,9 @@ import { createRng } from '@/lib/rng';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
||||
import { briefOverview, dedupeByName } from './overview';
|
||||
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
|
||||
import { parseAsiBonuses, parseAsiChoices, makeSkillResolver, parseBackgroundSkills, parseTraitSkills, loreSkillKey, type AsiChoice } from './origin';
|
||||
import { heritageMatchesAncestry } from './heritages';
|
||||
import { normalize5eSpellOpt, normalizePf2eSpellOpt, maxOfferedSpellLevel, spellMatchesClass, type SpellOpt } from './spells';
|
||||
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
|
||||
import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -35,8 +37,14 @@ interface Origin {
|
||||
name: string; desc: string; meta?: string; hp?: number; speed?: number;
|
||||
/** 5e racial ability score increases, applied to the final scores. */
|
||||
asiBonuses?: Partial<AbilityScores>;
|
||||
/** 5e choose-your-own racial increases (Half-Elf's two +1s) — prompted in the Abilities step. */
|
||||
asiChoices?: AsiChoice[];
|
||||
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
|
||||
skillGrants?: string[];
|
||||
/** 5e background either/or skill choices — one picker per entry (empty options = any skill). */
|
||||
skillChoices?: string[][];
|
||||
/** pf2e background Lore grants ("Scribing Lore") — persisted as custom lore:* skillRanks keys. */
|
||||
loreGrants?: string[];
|
||||
/** pf2e ancestry boosts: specific abilities + count of free boosts. */
|
||||
ancestryBoosts?: { fixed: AbilityKey[]; free: number };
|
||||
/** pf2e legacy ancestry flaw (-2), if any. */
|
||||
@@ -44,7 +52,6 @@ interface Origin {
|
||||
/** pf2e background boosts: choose-one options + free-boost count. */
|
||||
backgroundBoosts?: { options: AbilityKey[]; free: number };
|
||||
}
|
||||
interface SpellOpt { name: string; level: number; meta: string }
|
||||
|
||||
/** A class's "what it plays like" tag, to help newcomers pick. */
|
||||
function playstyle(c: RulesetClass): string {
|
||||
@@ -53,7 +60,10 @@ function playstyle(c: RulesetClass): string {
|
||||
return 'Skirmisher';
|
||||
}
|
||||
|
||||
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
|
||||
// PF2e templates carry no ability array: scores come from boosts, and the boost
|
||||
// seeding already favors the class key ability (reproducing the 18-key spread
|
||||
// legally); a flat statline would bypass the boost system and be discarded.
|
||||
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability?: AbilityScores }[]> = {
|
||||
'5e': [
|
||||
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
|
||||
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
|
||||
@@ -61,10 +71,10 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
|
||||
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
||||
],
|
||||
pf2e: [
|
||||
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
|
||||
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
|
||||
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
|
||||
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
||||
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter' },
|
||||
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard' },
|
||||
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue' },
|
||||
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -82,6 +92,14 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const [origins, setOrigins] = useState<Origin[]>([]);
|
||||
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
||||
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
||||
// PF2e versatile heritages (Tiefling, Aiuvarin, …) — sourced from the boost-less
|
||||
// rows of the ancestries file; offered in the heritage picker for ANY ancestry.
|
||||
const [versatileNames, setVersatileNames] = useState<ReadonlySet<string>>(new Set());
|
||||
// The PF2e datasets are fetched at runtime; a failed fetch must surface an
|
||||
// error + retry instead of an eternal "Loading…" (loadPf2e throws on !ok).
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [loadTick, setLoadTick] = useState(0);
|
||||
const retryLoad = () => { setLoadError(false); setLoadTick((t) => t + 1); };
|
||||
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
@@ -106,9 +124,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
return { ...rc, subclasses: [...extra.filter((s) => !have.has(s.name.toLowerCase())), ...rc.subclasses] };
|
||||
});
|
||||
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
});
|
||||
}).catch(() => { if (on) setLoadError(true); });
|
||||
return () => { on = false; };
|
||||
}, [system]);
|
||||
}, [system, loadTick]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
if (system === '5e') {
|
||||
@@ -120,23 +138,43 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
|
||||
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
|
||||
const asiBonuses = parseAsiBonuses(r.asi ?? '');
|
||||
const asiChoices = parseAsiChoices(r.asi ?? '');
|
||||
const asiSummary = ABILITIES.filter((a) => asiBonuses[a]).map((a) => `+${asiBonuses[a]} ${ABILITY_ABBR[a]}`).join(', ');
|
||||
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
|
||||
const choiceSummary = asiChoices.map((c) => `+${c.amount}×${c.count} choice`).join(', ');
|
||||
const meta = [asiSummary, choiceSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
|
||||
const skillGrants = parseTraitSkills(r.traits ?? '', resolve);
|
||||
return { name: r.name, desc, meta, asiBonuses, ...(skillGrants.length ? { skillGrants } : {}) };
|
||||
return {
|
||||
name: r.name, desc, meta, asiBonuses,
|
||||
...(asiChoices.length ? { asiChoices } : {}),
|
||||
...(speedFt ? { speed: Number(speedFt) } : {}),
|
||||
...(skillGrants.length ? { skillGrants } : {}),
|
||||
};
|
||||
}));
|
||||
});
|
||||
}).catch(() => { if (on) setLoadError(true); });
|
||||
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => {
|
||||
const skillGrants = parseBackgroundSkills(b.skills ?? '', resolve);
|
||||
return { name: b.name, desc: b.desc, meta: b.skills, ...(skillGrants.length ? { skillGrants } : {}) };
|
||||
})));
|
||||
const parsed = parseBackgroundSkills(b.skills ?? '', resolve);
|
||||
return {
|
||||
name: b.name, desc: b.desc, meta: b.skills,
|
||||
...(parsed.fixed.length ? { skillGrants: parsed.fixed } : {}),
|
||||
...(parsed.choices.length ? { skillChoices: parsed.choices } : {}),
|
||||
};
|
||||
}))).catch(() => { if (on) setLoadError(true); });
|
||||
} else {
|
||||
void loadPf2e('ancestries').then((rs) => {
|
||||
if (!on) return;
|
||||
setOrigins(dedupeByName(rs.map((r) => {
|
||||
// Versatile heritages (Tiefling, Aiuvarin, …) ride along in the AoN
|
||||
// ancestries file as boost-less, HP-less rows. They are heritages, not
|
||||
// ancestries — route them to the heritage picker instead of offering
|
||||
// broken 0-boost/8-HP "ancestries".
|
||||
const isVersatileRow = (r: (typeof rs)[number]) => r.attribute == null && r.hp == null;
|
||||
setVersatileNames(new Set(rs.filter(isVersatileRow).map((r) => String(r.name).toLowerCase())));
|
||||
setOrigins(dedupeByName(rs.filter((r) => !isVersatileRow(r)).map((r) => {
|
||||
const hp = typeof r.hp === 'number' ? r.hp : undefined;
|
||||
const speed = (r.speed as { land?: number } | undefined)?.land;
|
||||
const ancestryBoosts = parseAncestryBoosts(r.attribute);
|
||||
const parsedBoosts = parseAncestryBoosts(r.attribute);
|
||||
// Unparseable boost data: fall back to the "two free boosts" alternative
|
||||
// every ancestry may take (Player Core), rather than granting none.
|
||||
const ancestryBoosts = parsedBoosts.fixed.length || parsedBoosts.free ? parsedBoosts : { fixed: [], free: 2 };
|
||||
const ancestryFlaw = parseFlaw((r as Record<string, unknown>).attribute_flaw ?? (r as Record<string, unknown>).flaw);
|
||||
const fixedSummary = ancestryBoosts.fixed.map((a) => `+${ABILITY_ABBR[a]}`).join('/');
|
||||
const freeSummary = ancestryBoosts.free ? `+${ancestryBoosts.free} free` : '';
|
||||
@@ -151,24 +189,28 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
meta,
|
||||
};
|
||||
})));
|
||||
});
|
||||
}).catch(() => { if (on) setLoadError(true); });
|
||||
const resolvePf2e = makeSkillResolver(sys.skills);
|
||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => {
|
||||
// PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the
|
||||
// 16 core skills and are dropped here — they're tracked as custom Lore separately).
|
||||
// PF2e backgrounds train one core skill plus one Lore. The Lore doesn't map
|
||||
// to the 16 core skills — record it as a custom lore:* grant, don't drop it.
|
||||
const rawSkills = Array.isArray((b as Record<string, unknown>).skill) ? ((b as Record<string, unknown>).skill as string[]) : [];
|
||||
const skillGrants = rawSkills.map((s) => resolvePf2e(String(s))).filter((k): k is string => !!k);
|
||||
const loreGrants = rawSkills.map(String).filter((s) => /\blore\b/i.test(s)).map((s) => s.trim());
|
||||
const parsedBg = parseBackgroundBoosts(b.attribute);
|
||||
return {
|
||||
name: String(b.name),
|
||||
desc: briefOverview(String((b.description ?? b.text) ?? '')),
|
||||
backgroundBoosts: parseBackgroundBoosts(b.attribute),
|
||||
// Unparseable boost data: default to two free boosts (a background grants two).
|
||||
backgroundBoosts: parsedBg.options.length || parsedBg.free ? parsedBg : { options: [], free: 2 },
|
||||
...(skillGrants.length ? { skillGrants } : {}),
|
||||
...(loreGrants.length ? { loreGrants } : {}),
|
||||
};
|
||||
}))));
|
||||
})))).catch(() => { if (on) setLoadError(true); });
|
||||
}
|
||||
return () => { on = false; };
|
||||
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
|
||||
}, [system, sys.skills]);
|
||||
}, [system, sys.skills, loadTick]);
|
||||
|
||||
// ---- selections ----
|
||||
const [step, setStep] = useState(0);
|
||||
@@ -182,25 +224,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [ancestry, setAncestry] = useState('');
|
||||
// PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data
|
||||
// carries no ancestry link, so we match by name ("Ancient-Blooded Dwarf") and always
|
||||
// include versatile heritages (selectable by any ancestry).
|
||||
// carries no ancestry link, so ownership is resolved in ./heritages (curated map +
|
||||
// anchored name/text match); versatile heritages are selectable by any ancestry.
|
||||
const [heritage, setHeritage] = useState('');
|
||||
const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; versatile: boolean }[]>([]);
|
||||
const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; text: string }[]>([]);
|
||||
useEffect(() => {
|
||||
if (system !== 'pf2e' || allHeritages.length) return;
|
||||
let on = true;
|
||||
void loadPf2e('heritages').then((hs) => on && setAllHeritages(hs.map((h) => ({
|
||||
name: String(h.name),
|
||||
summary: briefOverview(String((h.summary ?? h.text) ?? '')),
|
||||
versatile: /versatile heritage/i.test(String(h.text ?? h.summary ?? '')),
|
||||
}))));
|
||||
text: String((h.text ?? h.summary) ?? ''),
|
||||
})))).catch(() => { if (on) setLoadError(true); });
|
||||
return () => { on = false; };
|
||||
}, [system, allHeritages.length]);
|
||||
}, [system, allHeritages.length, loadTick]);
|
||||
const heritageOptions = useMemo(() => {
|
||||
if (system !== 'pf2e' || !ancestry.trim()) return [];
|
||||
const want = ancestry.trim().toLowerCase();
|
||||
return allHeritages.filter((h) => h.name.toLowerCase().includes(want) || h.versatile);
|
||||
}, [system, ancestry, allHeritages]);
|
||||
const ancestryNames = origins.map((o) => o.name.toLowerCase());
|
||||
return allHeritages
|
||||
.map((h) => ({ ...h, versatile: versatileNames.has(h.name.toLowerCase()) || /versatile heritage/i.test(h.text) }))
|
||||
.filter((h) => heritageMatchesAncestry(h.name, h.text, ancestry, ancestryNames, versatileNames));
|
||||
}, [system, ancestry, allHeritages, origins, versatileNames]);
|
||||
useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage
|
||||
const [background, setBackground] = useState('');
|
||||
|
||||
@@ -242,23 +286,34 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
|
||||
// A boost slot's "source" — boosts within one source must target different abilities.
|
||||
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : id.startsWith('lvl') ? id.split('-')[0]! : 'free');
|
||||
// Seed a legal default assignment (distinct within each source) when slots change.
|
||||
// Seed a legal default assignment (distinct within each source) when slots change,
|
||||
// preserving the user's still-legal picks so revisiting steps or changing an
|
||||
// unrelated selection doesn't wipe manual boost assignments.
|
||||
useEffect(() => {
|
||||
if (!boostSlots) return;
|
||||
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
|
||||
const favored = keyOpts[0] ?? 'str';
|
||||
const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)];
|
||||
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed) };
|
||||
const next: Record<string, AbilityKey> = {};
|
||||
for (const s of boostSlots.slots) {
|
||||
const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
|
||||
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
|
||||
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
|
||||
next[s.id] = pick;
|
||||
u.add(pick);
|
||||
}
|
||||
setBoostPicks(next);
|
||||
setBoostPicks((prev) => {
|
||||
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed) };
|
||||
const next: Record<string, AbilityKey> = {};
|
||||
for (const s of boostSlots.slots) {
|
||||
const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
|
||||
const kept = prev[s.id];
|
||||
if (kept && s.options.includes(kept) && !u.has(kept)) { next[s.id] = kept; u.add(kept); }
|
||||
}
|
||||
for (const s of boostSlots.slots) {
|
||||
if (next[s.id]) continue;
|
||||
const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
|
||||
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
|
||||
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
|
||||
next[s.id] = pick;
|
||||
u.add(pick);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [boostSlots, classDef]);
|
||||
const unassignedBoosts = boostSlots ? boostSlots.slots.filter((s) => !boostPicks[s.id]).length : 0;
|
||||
|
||||
const pf2eAbilities = useMemo(() => {
|
||||
if (!boostSlots) return null;
|
||||
@@ -269,21 +324,45 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
// 5e: ability-score improvements gained by the input level for this class (4/8/12/16/19, +class extras).
|
||||
const asiCount = system === '5e' && selectedClass ? dnd5eAsiLevels(selectedClass.name).filter((l) => l <= level).length : 0;
|
||||
const [asiAlloc, setAsiAlloc] = useState<Partial<Record<AbilityKey, number>>>({});
|
||||
useEffect(() => { setAsiAlloc({}); }, [classSlug, level, system]); // reset when class/level changes
|
||||
// 5e RAW: each ASI may be swapped for a feat. The wizard has no feat picker, so
|
||||
// this opt-out lets the player leave points unspent on purpose (feats go on the
|
||||
// sheet after creation) instead of Next silently discarding them.
|
||||
const [keepAsiForFeats, setKeepAsiForFeats] = useState(false);
|
||||
useEffect(() => { setAsiAlloc({}); setKeepAsiForFeats(false); }, [classSlug, level, system]); // reset when class/level changes
|
||||
const asiUsed = ABILITIES.reduce((n, a) => n + (asiAlloc[a] ?? 0), 0);
|
||||
const asiRemaining = asiCount * 2 - asiUsed;
|
||||
|
||||
// 5e choose-your-own racial increases (Half-Elf's "two other of your choice", …):
|
||||
// one picker slot per owed pick, distinct within each choice group.
|
||||
const racialChoiceSlots = useMemo(() => {
|
||||
if (system === 'pf2e') return [];
|
||||
const out: { id: string; group: number; amount: number; options: AbilityKey[] }[] = [];
|
||||
(selectedOrigin?.asiChoices ?? []).forEach((c, gi) => {
|
||||
for (let i = 0; i < c.count; i++) out.push({ id: `${gi}-${i}`, group: gi, amount: c.amount, options: c.options });
|
||||
});
|
||||
return out;
|
||||
}, [system, selectedOrigin]);
|
||||
const [racialChoicePicks, setRacialChoicePicks] = useState<Record<string, AbilityKey | ''>>({});
|
||||
useEffect(() => { setRacialChoicePicks({}); }, [ancestry, system]);
|
||||
const racialChoicesValid = racialChoiceSlots.every((s) => racialChoicePicks[s.id]);
|
||||
// Fixed racial bonuses + the chosen ones — what the table's Race column shows.
|
||||
const racialBonuses = useMemo<Partial<AbilityScores>>(() => {
|
||||
const out: Partial<AbilityScores> = { ...(selectedOrigin?.asiBonuses ?? {}) };
|
||||
for (const s of racialChoiceSlots) {
|
||||
const a = racialChoicePicks[s.id];
|
||||
if (a) out[a] = (out[a] ?? 0) + s.amount;
|
||||
}
|
||||
return out;
|
||||
}, [selectedOrigin, racialChoiceSlots, racialChoicePicks]);
|
||||
|
||||
const abilities = useMemo<AbilityScores>(() => {
|
||||
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||
const out = {} as AbilityScores;
|
||||
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
|
||||
// 5e: fold the chosen race's ability score increases + any level-up ASIs into the final scores.
|
||||
if (selectedOrigin?.asiBonuses) {
|
||||
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
|
||||
}
|
||||
for (const a of ABILITIES) out[a] += asiAlloc[a] ?? 0;
|
||||
for (const a of ABILITIES) out[a] += (racialBonuses[a] ?? 0) + (asiAlloc[a] ?? 0);
|
||||
return out;
|
||||
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin, asiAlloc]);
|
||||
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, racialBonuses, asiAlloc]);
|
||||
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
|
||||
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
|
||||
|
||||
@@ -304,6 +383,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
|
||||
const adjustments: AbilityAdjustment[] = [];
|
||||
if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
|
||||
for (const s of racialChoiceSlots) { const a = racialChoicePicks[s.id]; if (a) adjustments.push({ label: 'Race (choice)', ability: a, kind: 'flat', amount: s.amount }); }
|
||||
for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); }
|
||||
return { base, adjustments };
|
||||
};
|
||||
@@ -331,17 +411,31 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const toggleSkill = (key: string) =>
|
||||
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
|
||||
|
||||
// 5e background either/or skill choices ("either Insight or History") — one
|
||||
// picker per owed choice, prompted on the Skills step.
|
||||
const bgSkillGroups = useMemo(() => selectedBackground?.skillChoices ?? [], [selectedBackground]);
|
||||
const [bgSkillPicks, setBgSkillPicks] = useState<string[]>([]);
|
||||
useEffect(() => { setBgSkillPicks(bgSkillGroups.map(() => '')); }, [bgSkillGroups]);
|
||||
const bgSkillPicksValid = bgSkillGroups.length === 0 || (bgSkillPicks.length === bgSkillGroups.length && bgSkillPicks.every(Boolean));
|
||||
|
||||
// PF2e background Lore grants — persisted as custom lore:* skillRanks keys.
|
||||
const loreGrants = useMemo(
|
||||
() => (selectedBackground?.loreGrants ?? []).map((name) => ({ key: loreSkillKey(name), label: name })),
|
||||
[selectedBackground],
|
||||
);
|
||||
|
||||
// Skill proficiencies granted (trained) by ancestry/race + background — shown to the
|
||||
// user so they don't waste free picks, and merged on top of class picks at build time.
|
||||
const grantedSkillSources = useMemo(() => {
|
||||
const out: { key: string; source: string }[] = [];
|
||||
const add = (keys: string[] | undefined, source: string) => {
|
||||
for (const k of keys ?? []) if (!out.some((o) => o.key === k)) out.push({ key: k, source });
|
||||
for (const k of keys ?? []) if (k && !out.some((o) => o.key === k)) out.push({ key: k, source });
|
||||
};
|
||||
add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race'));
|
||||
add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background');
|
||||
add(bgSkillPicks.filter(Boolean), selectedBackground?.name ?? 'Background');
|
||||
return out;
|
||||
}, [system, selectedOrigin, selectedBackground]);
|
||||
}, [system, selectedOrigin, selectedBackground, bgSkillPicks]);
|
||||
const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]);
|
||||
// Free picks exclude already-granted skills, so the user can't waste a choice on one.
|
||||
const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]);
|
||||
@@ -364,12 +458,41 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const [expertisePicks, setExpertisePicks] = useState<string[]>([]);
|
||||
useEffect(() => { setSkillIncPicks([]); setExpertisePicks([]); }, [classSlug, level, system]);
|
||||
// Seed sensible defaults from the chosen/granted skills (kept once the user edits).
|
||||
useEffect(() => {
|
||||
setExpertisePicks((prev) => Array.from({ length: expertiseCount }, (_, i) => prev[i] || skills[i] || skills[0] || ''));
|
||||
}, [expertiseCount, skills]);
|
||||
// Each slot gets a DISTINCT skill still in the pool; picks whose skill was
|
||||
// unchecked are dropped so stale Expertise never lands on an untrained skill.
|
||||
useEffect(() => {
|
||||
const pool = [...skills, ...grantedSkills];
|
||||
setSkillIncPicks((prev) => skillIncLevels.map((_, i) => prev[i] || pool[i % Math.max(1, pool.length)] || ''));
|
||||
setExpertisePicks((prev) => {
|
||||
const used = new Set<string>();
|
||||
return Array.from({ length: expertiseCount }, (_, i) => {
|
||||
const prevPick = prev[i];
|
||||
const pick = prevPick && pool.includes(prevPick) && !used.has(prevPick)
|
||||
? prevPick
|
||||
: pool.find((k) => !used.has(k)) ?? '';
|
||||
if (pick) used.add(pick);
|
||||
return pick;
|
||||
});
|
||||
});
|
||||
}, [expertiseCount, skills, grantedSkills]);
|
||||
// Seed skill increases with picks that respect the rank caps (Master needs the
|
||||
// level-7+ increase, Legendary 15+) instead of stacking every bump on one skill.
|
||||
useEffect(() => {
|
||||
const pool = [...skills, ...grantedSkills];
|
||||
setSkillIncPicks((prev) => {
|
||||
const ranks: Record<string, ProficiencyRank> = {};
|
||||
for (const k of pool) ranks[k] = 'trained';
|
||||
return skillIncLevels.map((earnedAt, i) => {
|
||||
const legal = (k: string) => incAllowed(ranks[k] ?? 'untrained', earnedAt);
|
||||
const prevPick = prev[i];
|
||||
const pick = (prevPick && legal(prevPick) ? prevPick : undefined)
|
||||
?? pool.find(legal) ?? allSkillKeys.find(legal) ?? '';
|
||||
if (pick) ranks[pick] = bumpRank(ranks[pick] ?? 'untrained');
|
||||
return pick;
|
||||
});
|
||||
});
|
||||
// allSkillKeys is derived from the per-system singleton; `system` (via
|
||||
// skillIncLevels) covers it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [skillIncLevels, skills, grantedSkills]);
|
||||
/** Skill ranks after base training + the first `uptoIdx` increases (for previews). */
|
||||
const ranksAfterIncreases = (uptoIdx: number): Record<string, ProficiencyRank> => {
|
||||
@@ -391,25 +514,32 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
useEffect(() => {
|
||||
if (!isCaster || allSpells.length) return;
|
||||
let on = true;
|
||||
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
|
||||
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
|
||||
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int?: number; school?: string; dnd_class?: string }[]).map(normalize5eSpellOpt))).catch(() => { if (on) setLoadError(true); });
|
||||
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => normalizePf2eSpellOpt(s as Record<string, unknown>)))).catch(() => { if (on) setLoadError(true); });
|
||||
return () => { on = false; };
|
||||
}, [isCaster, system, allSpells.length]);
|
||||
const spellResults = useMemo(() => {
|
||||
const q = spellQuery.trim().toLowerCase();
|
||||
const maxLevel = Math.min(9, Math.ceil(level / 2));
|
||||
return allSpells.filter((s) => s.level <= maxLevel && (!q || s.name.toLowerCase().includes(q))).slice(0, 60);
|
||||
}, [allSpells, spellQuery, level]);
|
||||
}, [isCaster, system, allSpells.length, loadTick]);
|
||||
const toggleSpell = (s: SpellOpt) =>
|
||||
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
|
||||
|
||||
// ---- derived build ----
|
||||
const built = useMemo(() => buildCharacter(system, {
|
||||
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
|
||||
...(grantedSkills.length ? { grantedSkills } : {}),
|
||||
...(grantedSkills.length || loreGrants.length ? { grantedSkills: [...grantedSkills, ...loreGrants.map((l) => l.key)] } : {}),
|
||||
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
|
||||
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
|
||||
}), [system, selectedClass, level, abilities, skills, grantedSkills, selectedOrigin]);
|
||||
}), [system, selectedClass, level, abilities, skills, grantedSkills, loreGrants, selectedOrigin]);
|
||||
|
||||
// Offer only what the class can cast: its spell list (5e) / tradition (PF2e),
|
||||
// capped at the top slot rank actually available at this level (rank 10 for a
|
||||
// level-19+ PF2e caster; cantrips only for a slot-less 5e half-caster at 1).
|
||||
const spellResults = useMemo(() => {
|
||||
const q = spellQuery.trim().toLowerCase();
|
||||
const maxLevel = maxOfferedSpellLevel(system, built.spellcasting.slots, built.spellcasting.pact);
|
||||
const className = selectedClass?.name ?? '';
|
||||
return allSpells
|
||||
.filter((s) => s.level <= maxLevel && spellMatchesClass(s, system, className) && (!q || s.name.toLowerCase().includes(q)))
|
||||
.slice(0, 60);
|
||||
}, [allSpells, spellQuery, system, built, selectedClass]);
|
||||
|
||||
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
|
||||
// just guidance: a few cantrips plus a handful of starting leveled spells.
|
||||
@@ -432,64 +562,91 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
|
||||
setAncestry('');
|
||||
setBackground('');
|
||||
// Reset the ability method too — a PF2e→5e switch must not inherit a stale
|
||||
// manual/point-buy spread left behind by a template or earlier edits.
|
||||
setMethod('standard');
|
||||
setPool([...STANDARD_ARRAY]);
|
||||
setAssignment(ABILITIES.map((_, i) => i));
|
||||
setPb([8, 8, 8, 8, 8, 8]);
|
||||
setStep(0);
|
||||
};
|
||||
|
||||
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
|
||||
const applyTemplate = (t: { label: string; className: string; ability?: AbilityScores }) => {
|
||||
const c = classes.find((x) => x.name === t.className);
|
||||
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
|
||||
// Templates can use scores above the point-buy cap (PF2e starts a key stat at 18),
|
||||
// which would softlock the point-buy step ("-Infinity / 27"). Use manual entry then.
|
||||
const fitsPointBuy = ABILITIES.every((a) => t.ability[a] >= POINT_BUY_MIN && t.ability[a] <= POINT_BUY_MAX);
|
||||
setMethod(fitsPointBuy ? 'pointbuy' : 'manual');
|
||||
setPb(ABILITIES.map((a) => t.ability[a]));
|
||||
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); setSpellPicks([]); setSpellQuery(''); }
|
||||
// 5e templates carry a statline; PF2e ones only pick the class (scores come
|
||||
// from boosts, which the seeding already steers toward the key ability).
|
||||
if (t.ability) {
|
||||
const ability = t.ability;
|
||||
// Templates can use scores above the point-buy cap, which would softlock the
|
||||
// point-buy step ("-Infinity / 27"). Use manual entry then.
|
||||
const fitsPointBuy = ABILITIES.every((a) => ability[a] >= POINT_BUY_MIN && ability[a] <= POINT_BUY_MAX);
|
||||
setMethod(fitsPointBuy ? 'pointbuy' : 'manual');
|
||||
setPb(ABILITIES.map((a) => ability[a]));
|
||||
}
|
||||
if (!name) setName(t.label.replace(/^[^A-Za-z]+/, ''));
|
||||
};
|
||||
|
||||
const freeSkillTarget = Math.min(skillCount, freeSkillOptions.length);
|
||||
const stepValid: Record<string, boolean> = {
|
||||
Class: !!selectedClass,
|
||||
Origin: true,
|
||||
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
|
||||
Skills: skills.length === Math.min(skillCount, freeSkillOptions.length)
|
||||
&& skillIncPicks.every(Boolean)
|
||||
&& expertisePicks.every(Boolean),
|
||||
Abilities: system === 'pf2e'
|
||||
? unassignedBoosts === 0
|
||||
: poolValid && pbValid && racialChoicesValid && (asiRemaining === 0 || (asiRemaining > 0 && keepAsiForFeats)),
|
||||
Skills: skills.length === freeSkillTarget
|
||||
&& bgSkillPicksValid
|
||||
&& skillIncPicks.every((k, i) => !!k && incAllowed(ranksAfterIncreases(i)[k] ?? 'untrained', skillIncLevels[i]!))
|
||||
&& expertisePicks.every(Boolean)
|
||||
&& new Set(expertisePicks).size === expertisePicks.length,
|
||||
Spells: true,
|
||||
Details: true,
|
||||
Review: true,
|
||||
};
|
||||
|
||||
// In-flight guard: a double-click on "Create character" must not create two
|
||||
// characters (IndexedDB writes are slow enough to double-fire).
|
||||
const [saving, setSaving] = useState(false);
|
||||
const finish = async () => {
|
||||
if (!selectedClass) return;
|
||||
const created = await charactersRepo.create(campaign?.id, {
|
||||
system, name: name.trim() || selectedClass.name, kind,
|
||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
||||
});
|
||||
// For classes outside the curated tables (esp. PF2e), fill saves from data.
|
||||
const saveRanks: Record<string, ProficiencyRank> = { ...built.saveRanks };
|
||||
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
|
||||
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
||||
if (!selectedClass || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await charactersRepo.create(campaign?.id, {
|
||||
system, name: name.trim() || selectedClass.name, kind,
|
||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
||||
});
|
||||
// For classes outside the curated tables (esp. PF2e), fill saves from data.
|
||||
const saveRanks: Record<string, ProficiencyRank> = { ...built.saveRanks };
|
||||
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
|
||||
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
||||
}
|
||||
// Spell picks are cleared on class change, but only casters save spells.
|
||||
const spells: SpellEntry[] = isCaster
|
||||
? spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }))
|
||||
: [];
|
||||
// Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks.
|
||||
const skillRanks: Record<string, ProficiencyRank> = { ...built.skillRanks };
|
||||
for (const k of expertisePicks) if (k) skillRanks[k] = 'expert';
|
||||
for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained');
|
||||
await charactersRepo.update(created.id, {
|
||||
...built,
|
||||
skillRanks,
|
||||
abilityBuild: buildAbilityBuild(),
|
||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
||||
spellcasting: { ...built.spellcasting, spells },
|
||||
...(background ? { background } : {}),
|
||||
...(heritage ? { heritage } : {}),
|
||||
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||
...(personality.trim() ? { personality: personality.trim() } : {}),
|
||||
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
|
||||
});
|
||||
onClose();
|
||||
void navigate({ to: '/characters/$characterId', params: { characterId: created.id } });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
|
||||
// Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks.
|
||||
const skillRanks: Record<string, ProficiencyRank> = { ...built.skillRanks };
|
||||
for (const k of expertisePicks) if (k) skillRanks[k] = 'expert';
|
||||
for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained');
|
||||
await charactersRepo.update(created.id, {
|
||||
...built,
|
||||
skillRanks,
|
||||
abilityBuild: buildAbilityBuild(),
|
||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
||||
spellcasting: { ...built.spellcasting, spells },
|
||||
...(background ? { background } : {}),
|
||||
...(heritage ? { heritage } : {}),
|
||||
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||
...(personality.trim() ? { personality: personality.trim() } : {}),
|
||||
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
|
||||
});
|
||||
onClose();
|
||||
void navigate({ to: '/characters/$characterId', params: { characterId: created.id } });
|
||||
};
|
||||
|
||||
const isLast = stepName === 'Review';
|
||||
@@ -505,7 +662,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
{step > 0 && <Button variant="secondary" onClick={() => go(-1)}>Back</Button>}
|
||||
{isLast
|
||||
? <Button variant="primary" disabled={!selectedClass} onClick={finish}>Create character</Button>
|
||||
? <Button variant="primary" disabled={!selectedClass || saving} onClick={finish}>{saving ? 'Creating…' : 'Create character'}</Button>
|
||||
: <Button variant="primary" disabled={!stepValid[stepName]} onClick={() => go(1)}>Next</Button>}
|
||||
</>
|
||||
}
|
||||
@@ -549,7 +706,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
<div className="mb-1 smallcaps">Choose a class</div>
|
||||
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{classes.map((c) => (
|
||||
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
|
||||
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); setSpellPicks([]); setSpellQuery(''); }}
|
||||
className={cn('rounded-lg border p-2 text-left', classSlug === c.slug ? 'border-accent bg-accent/5' : 'border-line bg-surface hover:border-accent/60')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display font-semibold text-ink">{c.name}</span>
|
||||
@@ -559,7 +716,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
|
||||
</button>
|
||||
))}
|
||||
{classes.length === 0 && <p className="text-sm text-muted">Loading classes…</p>}
|
||||
{classes.length === 0 && (loadError
|
||||
? <LoadFailed what="classes" onRetry={retryLoad} />
|
||||
: <p className="text-sm text-muted">Loading classes…</p>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -604,6 +763,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
|
||||
{stepName === 'Origin' && (
|
||||
<div className="space-y-3">
|
||||
{loadError && (origins.length === 0 || backgrounds.length === 0) && (
|
||||
<LoadFailed what={system === 'pf2e' ? 'ancestries & backgrounds' : 'races & backgrounds'} onRetry={retryLoad} />
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
@@ -665,6 +827,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{unassignedBoosts > 0 && (
|
||||
<p className="text-sm text-warning">
|
||||
Assign {unassignedBoosts} more boost{unassignedBoosts === 1 ? '' : 's'} to continue — every boost column needs a highlighted cell.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -702,13 +869,49 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
<p className="text-sm text-warning">Assign each value to a different ability.</p>
|
||||
)}
|
||||
|
||||
{/* Choose-your-own racial increases (Half-Elf "+1 to two others", …) */}
|
||||
{racialChoiceSlots.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-ink">
|
||||
Racial bonus choices <span className="text-muted">({selectedOrigin?.name})</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{racialChoiceSlots.map((slot) => (
|
||||
<label key={slot.id} className="text-xs text-muted">
|
||||
+{slot.amount} to…
|
||||
<Select
|
||||
className="mt-0.5"
|
||||
aria-label={`Racial +${slot.amount} choice`}
|
||||
value={racialChoicePicks[slot.id] ?? ''}
|
||||
onChange={(e) => setRacialChoicePicks((p) => ({ ...p, [slot.id]: e.target.value as AbilityKey | '' }))}
|
||||
>
|
||||
<option value="">— choose an ability —</option>
|
||||
{slot.options.map((a) => (
|
||||
<option
|
||||
key={a}
|
||||
value={a}
|
||||
disabled={racialChoiceSlots.some((o) => o.group === slot.group && o.id !== slot.id && racialChoicePicks[o.id] === a)}
|
||||
>
|
||||
{ABILITY_LABELS[a]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!racialChoicesValid && (
|
||||
<p className="mt-1 text-xs text-warning">Choose the ability for each racial bonus to continue.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MPMB-style ability table */}
|
||||
<Wizard5eAbilityTable
|
||||
method={method}
|
||||
pool={pool}
|
||||
assignment={assignment}
|
||||
pb={pb}
|
||||
racial={selectedOrigin?.asiBonuses ?? {}}
|
||||
racial={racialBonuses}
|
||||
asiAlloc={asiAlloc}
|
||||
asiCount={asiCount}
|
||||
keyAbilities={selectedClass?.keyAbilities}
|
||||
@@ -729,6 +932,20 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
setAsiAlloc((p) => ({ ...p, [ability]: Math.max(0, (p[ability] ?? 0) + delta) }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 5e RAW: each ASI may be a feat instead — don't silently drop unspent points. */}
|
||||
{asiCount > 0 && asiRemaining > 0 && (
|
||||
<div className="rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
|
||||
<p className="text-warning">
|
||||
{asiRemaining} ability point{asiRemaining === 1 ? '' : 's'} unspent — allocate {asiRemaining === 1 ? 'it' : 'them'} in
|
||||
the ASI column, or skip below. Each Ability Score Improvement can also be traded for a feat.
|
||||
</p>
|
||||
<label className="mt-1 flex items-center gap-2 text-muted">
|
||||
<input type="checkbox" checked={keepAsiForFeats} onChange={(e) => setKeepAsiForFeats(e.target.checked)} />
|
||||
Leave {asiRemaining === 1 ? 'this point' : 'these points'} unspent — I’ll take {asiRemaining > 2 ? 'feats' : 'a feat'} instead (add it in the sheet’s Feats section after creation)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -740,7 +957,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}).
|
||||
</p>
|
||||
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
|
||||
{grantedSkillSources.length > 0 && (
|
||||
{(grantedSkillSources.length > 0 || loreGrants.length > 0) && (
|
||||
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
|
||||
<div className="mb-1 smallcaps text-[11px]">Already Trained (from your origin)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -749,9 +966,46 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
✓ {skillLabel(g.key)} <span className="text-muted">· {g.source}</span>
|
||||
</span>
|
||||
))}
|
||||
{loreGrants.map((l) => (
|
||||
<span key={l.key} className="rounded border border-accent/40 bg-accent/5 px-2 py-0.5 text-xs text-ink" title={`Granted by ${selectedBackground?.name ?? 'Background'}`}>
|
||||
✓ {l.label} <span className="text-muted">· {selectedBackground?.name ?? 'Background'}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 5e background either/or skill choices — prompted, not silently dropped. */}
|
||||
{bgSkillGroups.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1 text-sm text-ink">
|
||||
Background skill choice{bgSkillGroups.length === 1 ? '' : 's'} <span className="text-muted">({selectedBackground?.name})</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{bgSkillGroups.map((opts, i) => (
|
||||
<Select
|
||||
key={i}
|
||||
aria-label={`Background skill choice ${i + 1}`}
|
||||
value={bgSkillPicks[i] ?? ''}
|
||||
onChange={(e) => setBgSkillPicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}
|
||||
>
|
||||
<option value="">— pick a skill —</option>
|
||||
{(opts.length ? opts : allSkillKeys).map((k) => (
|
||||
<option
|
||||
key={k}
|
||||
value={k}
|
||||
disabled={(selectedBackground?.skillGrants ?? []).includes(k) || (bgSkillPicks.includes(k) && bgSkillPicks[i] !== k)}
|
||||
>
|
||||
{skillLabel(k)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
{!bgSkillPicksValid && (
|
||||
<p className="mt-1 text-xs text-warning">Pick your background skill{bgSkillGroups.length === 1 ? '' : 's'} to continue.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
return tip?.skillSuggestions ? (
|
||||
@@ -773,8 +1027,13 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{skills.length < Math.min(skillCount, freeSkillOptions.length) && (
|
||||
<p className="mt-2 text-xs text-warning">Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.</p>
|
||||
{skills.length < freeSkillTarget && (
|
||||
<p className="mt-2 text-xs text-warning">Select {freeSkillTarget - skills.length} more {freeSkillTarget - skills.length === 1 ? 'skill' : 'skills'} to continue.</p>
|
||||
)}
|
||||
{skills.length > freeSkillTarget && (
|
||||
<p className="mt-2 text-xs text-warning">
|
||||
Unselect {skills.length - freeSkillTarget} {skills.length - freeSkillTarget === 1 ? 'skill' : 'skills'} to continue — this class grants {freeSkillTarget} free pick{freeSkillTarget === 1 ? '' : 's'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */}
|
||||
@@ -852,7 +1111,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{allSpells.length === 0 && <p className="text-sm text-muted">Loading spells…</p>}
|
||||
{allSpells.length === 0 && (loadError
|
||||
? <LoadFailed what="spells" onRetry={retryLoad} />
|
||||
: <p className="text-sm text-muted">Loading spells…</p>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -898,6 +1159,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
<Review label="Trained skills" value={[
|
||||
...skills.map(skillLabel),
|
||||
...grantedSkillSources.map((g) => `${skillLabel(g.key)} (${g.source})`),
|
||||
...loreGrants.map((l) => `${l.label} (${selectedBackground?.name ?? 'Background'})`),
|
||||
].join(', ') || '—'} />
|
||||
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
|
||||
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
|
||||
@@ -908,6 +1170,15 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
);
|
||||
}
|
||||
|
||||
function LoadFailed({ what, onRetry }: { what: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="rounded-md border border-danger/40 bg-danger/5 p-2 text-sm">
|
||||
<p className="text-danger">Couldn’t load {what} — check your connection.</p>
|
||||
<Button size="sm" variant="secondary" className="mt-1" onClick={onRetry}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OriginPicker({ title, options, value, onPick }: { title: string; options: Origin[]; value: string; onPick: (v: string) => void }) {
|
||||
const [q, setQ] = useState('');
|
||||
const filtered = options.filter((o) => !q || o.name.toLowerCase().includes(q.toLowerCase())).slice(0, 60);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { heritageAncestries, heritageMatchesAncestry, isVersatileHeritage } from './heritages';
|
||||
|
||||
const ANCESTRIES = ['dwarf', 'elf', 'gnome', 'goblin', 'halfling', 'human', 'half-elf', 'half-orc', 'hobgoblin', 'ratfolk', 'kobold', 'sprite', 'conrasu', 'fetchling'];
|
||||
const NO_VERSATILE = new Set<string>();
|
||||
|
||||
describe('heritageAncestries', () => {
|
||||
it('matches name-suffixed heritages to their ancestry', () => {
|
||||
expect(heritageAncestries('Ancient-Blooded Dwarf', '', ANCESTRIES)).toEqual(['dwarf']);
|
||||
expect(heritageAncestries('Wellspring Gnome', '', ANCESTRIES)).toEqual(['gnome']);
|
||||
});
|
||||
it('does not attach goblin heritages to hobgoblin (or vice versa)', () => {
|
||||
expect(heritageAncestries('Elfbane Hobgoblin', '', ANCESTRIES)).toEqual(['hobgoblin']);
|
||||
expect(heritageAncestries('Snow Goblin', '', ANCESTRIES)).toEqual(['goblin']);
|
||||
});
|
||||
it('does not attach a Half-Elf heritage to elf', () => {
|
||||
expect(heritageAncestries('Ekujae Half-Elf', '', ANCESTRIES)).toEqual(['half-elf']);
|
||||
});
|
||||
it('resolves curated names that never mention their ancestry', () => {
|
||||
expect(heritageAncestries('Skilled Heritage', 'You become trained in one skill of your choice.', ANCESTRIES)).toEqual(['human']);
|
||||
expect(heritageAncestries('Rite of Knowing', 'You enhanced your exoskeleton…', ANCESTRIES)).toEqual(['conrasu']);
|
||||
expect(heritageAncestries('Snow Rat', 'You have a thicker coat…', ANCESTRIES)).toEqual(['ratfolk']);
|
||||
});
|
||||
it('falls back to a text mention', () => {
|
||||
expect(heritageAncestries('Wisp Wanderer', 'Your ancestors lived deeper underground than other ratfolk.', ANCESTRIES)).toEqual(['ratfolk']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heritageMatchesAncestry', () => {
|
||||
it('offers versatile heritages to any ancestry', () => {
|
||||
const versatile = new Set(['tiefling', 'aiuvarin', 'dromaar']);
|
||||
for (const anc of ['dwarf', 'goblin', 'human']) {
|
||||
expect(heritageMatchesAncestry('Tiefling', 'You descend from fiends…', anc, ANCESTRIES, versatile)).toBe(true);
|
||||
expect(heritageMatchesAncestry('Aiuvarin', 'You have elves in your family tree.', anc, ANCESTRIES, versatile)).toBe(true);
|
||||
}
|
||||
expect(isVersatileHeritage('Versatile Heritage', 'Humanity’s versatile heritage…', NO_VERSATILE)).toBe(true);
|
||||
});
|
||||
it('offers owned heritages only to their ancestry', () => {
|
||||
expect(heritageMatchesAncestry('Ancient-Blooded Dwarf', '', 'dwarf', ANCESTRIES, NO_VERSATILE)).toBe(true);
|
||||
expect(heritageMatchesAncestry('Ancient-Blooded Dwarf', '', 'elf', ANCESTRIES, NO_VERSATILE)).toBe(false);
|
||||
});
|
||||
it('keeps unknown heritages selectable everywhere', () => {
|
||||
expect(heritageMatchesAncestry('Totally Homebrew', 'No ancestry mentioned.', 'dwarf', ANCESTRIES, NO_VERSATILE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AoN dataset integrity', () => {
|
||||
const root = resolve(__dirname, '../../../..');
|
||||
const heritages = JSON.parse(readFileSync(resolve(root, 'public/data/pf2e/heritages.json'), 'utf8')) as { name: string; text?: string }[];
|
||||
const ancestries = JSON.parse(readFileSync(resolve(root, 'public/data/pf2e/ancestries.json'), 'utf8')) as { name: string; attribute?: unknown; hp?: unknown }[];
|
||||
const versatileNames = new Set(ancestries.filter((a) => a.attribute == null && a.hp == null).map((a) => a.name.toLowerCase()));
|
||||
const realNames = [...new Set(ancestries.filter((a) => a.attribute != null || a.hp != null).map((a) => a.name.toLowerCase()))];
|
||||
|
||||
it('flags the known versatile heritages', () => {
|
||||
for (const n of ['tiefling', 'aasimar', 'changeling', 'dhampir', 'aiuvarin', 'dromaar', 'nephilim']) {
|
||||
expect(versatileNames.has(n), n).toBe(true);
|
||||
}
|
||||
});
|
||||
it('resolves every non-versatile heritage to at least one ancestry', () => {
|
||||
const unresolved = heritages
|
||||
.filter((h) => !isVersatileHeritage(h.name, String(h.text ?? ''), versatileNames))
|
||||
.filter((h) => heritageAncestries(h.name, String(h.text ?? ''), realNames).length === 0)
|
||||
.map((h) => h.name);
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
it('reaches every legacy Fetchling heritage from the fetchling ancestry', () => {
|
||||
// Remaster names contain "Fetchling"; this guards the general suffix match.
|
||||
const fetchling = heritages.filter((h) =>
|
||||
heritageMatchesAncestry(h.name, String(h.text ?? ''), 'fetchling', realNames, versatileNames)
|
||||
&& /fetchling/i.test(h.name));
|
||||
expect(fetchling.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* PF2e heritage ← ancestry linking. The AoN heritage data carries no ancestry
|
||||
* field, so ownership is resolved by layering three signals:
|
||||
* 1. a curated name → ancestry map for heritages whose names never mention
|
||||
* their ancestry ("Skilled Heritage" → human, "Rite of Knowing" → conrasu),
|
||||
* 2. an anchored word match on the heritage name ("Ancient-Blooded Dwarf" —
|
||||
* word-anchored so goblin heritages don't attach to hobgoblin, and
|
||||
* "Ekujae Half-Elf" doesn't attach to elf),
|
||||
* 3. a word match on the heritage text ("…lived deeper underground than
|
||||
* other ratfolk…") as a fallback.
|
||||
* Versatile heritages (Tiefling, Aiuvarin, Dromaar, …) attach to ANY ancestry;
|
||||
* the AoN dataset models them as boost-less/HP-less rows of the ancestries
|
||||
* file, so their names arrive via `versatileNames` (plus the literal
|
||||
* "versatile heritage" phrase in the text).
|
||||
*/
|
||||
|
||||
const CURATED: Record<string, string> = {
|
||||
// legacy human heritages (Core Rulebook) carry no "Human" in the name
|
||||
'skilled heritage': 'human',
|
||||
// ratfolk ("ysoki") heritages are named "… Rat"
|
||||
'deep rat': 'ratfolk', 'desert rat': 'ratfolk', 'longsnout rat': 'ratfolk', 'sewer rat': 'ratfolk',
|
||||
'shadow rat': 'ratfolk', 'snow rat': 'ratfolk', 'tunnel rat': 'ratfolk',
|
||||
// sprite heritages (Ancestry Guide + Tian Xia Character Guide)
|
||||
'draxie': 'sprite', 'grig': 'sprite', 'melixie': 'sprite', 'nyktera': 'sprite', 'pixie': 'sprite',
|
||||
'dijiang': 'sprite', 'gandharva': 'sprite', 'kanchil': 'sprite', 'leungli': 'sprite',
|
||||
// conrasu rites (The Mwangi Expanse)
|
||||
'rite of invocation': 'conrasu', 'rite of knowing': 'conrasu', 'rite of light': 'conrasu',
|
||||
'rite of passage': 'conrasu', 'rite of reinforcement': 'conrasu',
|
||||
// ghoran (Impossible Lands)
|
||||
'ancient ash': 'ghoran', 'enchanting lily': 'ghoran', 'strong oak': 'ghoran', 'thorned rose': 'ghoran',
|
||||
// kashrishi (Impossible Lands)
|
||||
'athamasi': 'kashrishi', 'lethoci': 'kashrishi', 'nascent': 'kashrishi', 'trogloshi': 'kashrishi', 'xyloshi': 'kashrishi',
|
||||
// awakened animal (Howl of the Wild)
|
||||
'climbing animal': 'awakened animal', 'flying animal': 'awakened animal',
|
||||
'running animal': 'awakened animal', 'swimming animal': 'awakened animal',
|
||||
// wayang shadow heritages (Tian Xia Character Guide)
|
||||
'shadow of the courtier': 'wayang', 'shadow of the hermit': 'wayang', 'shadow of the sailor': 'wayang',
|
||||
'shadow of the smith': 'wayang', 'shadow of the wanderer': 'wayang',
|
||||
// yaksha vows (Tian Xia Character Guide)
|
||||
'deny the firstborn pursuit': 'yaksha', "deny lady nanbyo's charity": 'yaksha', "deny the traitor's rebirth": 'yaksha',
|
||||
'respite of cloudless paths': 'yaksha', 'respite of loam and leaf': 'yaksha', 'respite of a thousand roofs': 'yaksha',
|
||||
// dragonet drakes (Draconic Codex)
|
||||
'homing drake': 'dragonet', 'house drake': 'dragonet',
|
||||
'three kobolds in a trench coat': 'kobold',
|
||||
};
|
||||
|
||||
/** Word-anchored, plural-tolerant match ("kobolds" ⊃ kobold; "hobgoblin" ⊅ goblin; "half-elf" ⊅ elf). */
|
||||
function wordMatch(haystack: string, word: string): boolean {
|
||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`(^|[^a-z-])${esc}(s)?($|[^a-z-])`, 'i').test(haystack);
|
||||
}
|
||||
|
||||
/** Is this heritage a versatile heritage (attachable to any ancestry)? */
|
||||
export function isVersatileHeritage(name: string, text: string, versatileNames: ReadonlySet<string>): boolean {
|
||||
return versatileNames.has(name.trim().toLowerCase()) || /versatile heritage/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ancestries (lower-case names) a non-versatile heritage belongs to.
|
||||
* Empty result = unknown; callers should keep unknown heritages selectable
|
||||
* rather than hiding them.
|
||||
*/
|
||||
export function heritageAncestries(name: string, text: string, ancestryNames: readonly string[]): string[] {
|
||||
const n = name.trim().toLowerCase();
|
||||
const curated = CURATED[n];
|
||||
if (curated) return [curated];
|
||||
const byName = ancestryNames.filter((a) => wordMatch(n, a));
|
||||
if (byName.length) return byName;
|
||||
return ancestryNames.filter((a) => wordMatch(text, a));
|
||||
}
|
||||
|
||||
/** Should this heritage be offered for the given ancestry? */
|
||||
export function heritageMatchesAncestry(
|
||||
name: string,
|
||||
text: string,
|
||||
ancestry: string,
|
||||
ancestryNames: readonly string[],
|
||||
versatileNames: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isVersatileHeritage(name, text, versatileNames)) return true;
|
||||
const owners = heritageAncestries(name, text, ancestryNames);
|
||||
return owners.length === 0 || owners.includes(ancestry.trim().toLowerCase());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
|
||||
import { parseAsiBonuses, parseAsiChoices, makeSkillResolver, parseBackgroundSkills, parseTraitSkills, loreSkillKey } from './origin';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
|
||||
const resolve = makeSkillResolver(getSystem('5e').skills);
|
||||
@@ -21,15 +21,65 @@ describe('parseAsiBonuses', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAsiChoices', () => {
|
||||
it('parses Half-Elf "two other of your choice" excluding the fixed ability', () => {
|
||||
const cs = parseAsiChoices('Your Charisma score increases by 2, and two other ability scores of your choice increase by 1.');
|
||||
expect(cs).toEqual([{ count: 2, amount: 1, options: ['str', 'dex', 'con', 'int', 'wis'] }]);
|
||||
});
|
||||
it('parses Gearforged "Two different of your choice" from all abilities', () => {
|
||||
const cs = parseAsiChoices('Two different ability scores of your choice increase by 1.');
|
||||
expect(cs).toEqual([{ count: 2, amount: 1, options: ['str', 'dex', 'con', 'int', 'wis', 'cha'] }]);
|
||||
});
|
||||
it('parses Erina "either your Wisdom or Charisma"', () => {
|
||||
const cs = parseAsiChoices('Your Dexterity score increases by 2, and you can choose to increase either your Wisdom or Charisma score by 1.');
|
||||
expect(cs).toEqual([{ count: 1, amount: 1, options: ['wis', 'cha'] }]);
|
||||
});
|
||||
it('parses both Shade choice clauses (3 granted points total)', () => {
|
||||
const cs = parseAsiChoices('Your Charisma score increases by 1, and one other ability score of your choice increases by 1. Choose one ability score that is increased by your Living Origin or by one of its subraces. That ability score increases by 1.');
|
||||
expect(cs).toEqual([
|
||||
{ count: 1, amount: 1, options: ['str', 'dex', 'con', 'int', 'wis'] },
|
||||
{ count: 1, amount: 1, options: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
|
||||
]);
|
||||
});
|
||||
it('finds no choices in Human or fixed-only prose', () => {
|
||||
expect(parseAsiChoices('Your ability scores each increase by 1.')).toEqual([]);
|
||||
expect(parseAsiChoices('Your Constitution score increases by 2.')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBackgroundSkills', () => {
|
||||
it('takes both fixed skills (Acolyte)', () => {
|
||||
expect(parseBackgroundSkills('Insight, Religion', resolve)).toEqual(['insight', 'religion']);
|
||||
expect(parseBackgroundSkills('Insight, Religion', resolve)).toEqual({ fixed: ['insight', 'religion'], choices: [] });
|
||||
});
|
||||
it('stops at a choose-one clause (Charlatan)', () => {
|
||||
expect(parseBackgroundSkills('Deception, and either Culture, Insight, or Sleight of Hand.', resolve)).toEqual(['deception']);
|
||||
it('keeps the fixed skill and surfaces the chooser (Charlatan)', () => {
|
||||
expect(parseBackgroundSkills('Deception, and either Culture, Insight, or Sleight of Hand.', resolve))
|
||||
.toEqual({ fixed: ['deception'], choices: [['insight', 'sleight-of-hand']] });
|
||||
});
|
||||
it('handles "plus your choice" (Crime Syndicate Member)', () => {
|
||||
expect(parseBackgroundSkills('Deception, plus your choice of one between Sleight of Hand or Stealth.', resolve)).toEqual(['deception']);
|
||||
it('handles "plus your choice of one between" (Crime Syndicate Member)', () => {
|
||||
expect(parseBackgroundSkills('Deception, plus your choice of one between Sleight of Hand or Stealth.', resolve))
|
||||
.toEqual({ fixed: ['deception'], choices: [['sleight-of-hand', 'stealth']] });
|
||||
});
|
||||
it('keeps the fixed skill when the chooser has no comma (Innkeeper)', () => {
|
||||
expect(parseBackgroundSkills('Insight plus one of your choice from among Intimidation or Persuasion', resolve))
|
||||
.toEqual({ fixed: ['insight'], choices: [['intimidation', 'persuasion']] });
|
||||
});
|
||||
it('grants both fixed skills joined by "and" (Recovered Cultist)', () => {
|
||||
expect(parseBackgroundSkills('Religion and Deception.', resolve))
|
||||
.toEqual({ fixed: ['religion', 'deception'], choices: [] });
|
||||
});
|
||||
it('yields two any-skill picks (Guildmember)', () => {
|
||||
expect(parseBackgroundSkills('Two of your choice.', resolve)).toEqual({ fixed: [], choices: [[], []] });
|
||||
});
|
||||
it('yields two constrained picks (Lyceum Student)', () => {
|
||||
expect(parseBackgroundSkills('Your choice of two from among Arcana, History, and Persuasion.', resolve))
|
||||
.toEqual({ fixed: [], choices: [['arcana', 'history', 'persuasion'], ['arcana', 'history', 'persuasion']] });
|
||||
});
|
||||
it('is empty for an empty string (Fate-Touched)', () => {
|
||||
expect(parseBackgroundSkills('', resolve)).toEqual({ fixed: [], choices: [] });
|
||||
});
|
||||
it('does not split multi-word skills on inner "and" (Farmer)', () => {
|
||||
expect(parseBackgroundSkills('Nature, and either Animal Handling or Survival.', resolve))
|
||||
.toEqual({ fixed: ['nature'], choices: [['animal-handling', 'survival']] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,3 +91,11 @@ describe('parseTraitSkills', () => {
|
||||
expect(parseTraitSkills('Menacing. You gain proficiency in the Intimidation skill.', resolve)).toEqual(['intimidation']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loreSkillKey', () => {
|
||||
it('namespaces and slugs a background Lore grant', () => {
|
||||
expect(loreSkillKey('Scribing Lore')).toBe('lore:scribing');
|
||||
expect(loreSkillKey('Warfare Lore')).toBe('lore:warfare');
|
||||
expect(loreSkillKey('Stock Market Lore')).toBe('lore:stock-market');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ const ABILITY_BY_WORD: Record<string, AbilityKey> = {
|
||||
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
|
||||
};
|
||||
|
||||
const COUNT_WORD: Record<string, number> = { one: 1, two: 2, three: 3 };
|
||||
|
||||
/**
|
||||
* Parse a 5e race's ASI prose into structured ability bonuses. Handles the Human
|
||||
* "Your ability scores each increase by 1" case and the usual "Your X score
|
||||
@@ -27,6 +29,47 @@ export function parseAsiBonuses(asi: string): Partial<AbilityScores> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A choose-N-abilities racial increase (Half-Elf's "two other ability scores of your choice"). */
|
||||
export interface AsiChoice {
|
||||
/** how many distinct abilities the player picks */
|
||||
count: number;
|
||||
/** the increase applied to each picked ability */
|
||||
amount: number;
|
||||
/** abilities the player may pick from */
|
||||
options: AbilityKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the choose-your-own clauses of a 5e race's ASI prose. Fixed increases are
|
||||
* handled by parseAsiBonuses; each entry returned here is one chooser the wizard
|
||||
* must surface. Covers "two other ability scores of your choice increase by 1"
|
||||
* (Half-Elf — "other" excludes the fixed-bonus abilities), "Two different ability
|
||||
* scores of your choice increase by 1" (Gearforged), "either your Wisdom or
|
||||
* Charisma score by 1" (Erina), and Shade's Living Origin pick.
|
||||
*/
|
||||
export function parseAsiChoices(asi: string): AsiChoice[] {
|
||||
const out: AsiChoice[] = [];
|
||||
const fixed = parseAsiBonuses(asi);
|
||||
for (const m of asi.matchAll(/\b(one|two|three)\s+(other\s+|different\s+)?ability scores?\s+of your choice\s+(?:each\s+)?increases?\s+by\s+(\d+)/gi)) {
|
||||
const count = COUNT_WORD[m[1]!.toLowerCase()] ?? 1;
|
||||
// "other" = other than the abilities already granted a fixed increase.
|
||||
const options = /other/i.test(m[2] ?? '') ? ABILITIES.filter((a) => !fixed[a]) : [...ABILITIES];
|
||||
out.push({ count, amount: Number(m[3]), options });
|
||||
}
|
||||
for (const m of asi.matchAll(/either your (strength|dexterity|constitution|intelligence|wisdom|charisma) or (strength|dexterity|constitution|intelligence|wisdom|charisma) score (?:increases? )?by (\d+)/gi)) {
|
||||
const a = ABILITY_BY_WORD[m[1]!.toLowerCase()];
|
||||
const b = ABILITY_BY_WORD[m[2]!.toLowerCase()];
|
||||
if (a && b) out.push({ count: 1, amount: Number(m[3]), options: [a, b] });
|
||||
}
|
||||
// Shade: "Choose one ability score that is increased by your Living Origin […].
|
||||
// That ability score increases by 1." The Living Origin (subrace) restriction
|
||||
// isn't modelled, so offer all six — the player applies the constraint.
|
||||
for (const m of asi.matchAll(/choose one ability score that is increased by[^.]*\.\s*that ability score increases by (\d+)/gi)) {
|
||||
out.push({ count: 1, amount: Number(m[1]), options: [...ABILITIES] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A "skill label/key → key" resolver for the active system. */
|
||||
export function makeSkillResolver(skills: readonly { key: string; label: string }[]): (name: string) => string | undefined {
|
||||
const byLabel = new Map<string, string>();
|
||||
@@ -34,17 +77,63 @@ export function makeSkillResolver(skills: readonly { key: string; label: string
|
||||
return (name) => byLabel.get(name.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Fixed skill proficiencies a 5e background grants (ignores "either/or/choice" clauses). */
|
||||
export function parseBackgroundSkills(skills: string, resolve: (n: string) => string | undefined): string[] {
|
||||
const out: string[] = [];
|
||||
for (let token of skills.split(',')) {
|
||||
token = token.replace(/\b(and|plus)\b/gi, '').replace(/\.$/, '').trim();
|
||||
if (!token) continue;
|
||||
if (/either|\bor\b|choice|between/i.test(token)) break; // a choose-one clause — stop here
|
||||
const key = resolve(token);
|
||||
if (key && !out.includes(key)) out.push(key);
|
||||
/** Parsed 5e background skill grants: fixed proficiencies plus choose-one pickers. */
|
||||
export interface BackgroundSkills {
|
||||
/** skill keys always granted */
|
||||
fixed: string[];
|
||||
/** one entry per pick the player owes; empty options = choose from any skill */
|
||||
choices: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 5e background's skill prose into fixed grants and choose-clauses.
|
||||
* Handles "Insight, Religion", "Religion and Deception.", trailing choosers
|
||||
* ("Deception, and either Culture, Insight, or Sleight of Hand.", "Insight plus
|
||||
* one of your choice from among Intimidation or Persuasion"), and pure-choice
|
||||
* forms ("Two of your choice.", "Your choice of two from among Arcana, History,
|
||||
* and Persuasion.").
|
||||
*/
|
||||
export function parseBackgroundSkills(skills: string, resolve: (n: string) => string | undefined): BackgroundSkills {
|
||||
const fixed: string[] = [];
|
||||
const choices: string[][] = [];
|
||||
let s = skills.trim().replace(/\.\s*$/, '');
|
||||
if (!s) return { fixed, choices };
|
||||
|
||||
const countOf = (w: string | undefined) => COUNT_WORD[(w ?? 'one').toLowerCase()] ?? 1;
|
||||
const parseOptions = (list: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const raw of list.split(/,|\bor\b|\band\b/i)) {
|
||||
const key = resolve(raw.trim());
|
||||
if (key && !out.includes(key)) out.push(key);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const pushChoice = (count: number, options: string[]) => {
|
||||
for (let i = 0; i < count; i++) choices.push(options);
|
||||
};
|
||||
|
||||
// "Two of your choice." — N picks from any skill.
|
||||
let m = /^(one|two|three)\s+of your choice$/i.exec(s);
|
||||
if (m) { pushChoice(countOf(m[1]), []); return { fixed, choices }; }
|
||||
|
||||
// "Your choice of two from among Arcana, History, and Persuasion."
|
||||
m = /^your choice of (one|two|three) from among (.+)$/i.exec(s);
|
||||
if (m) { pushChoice(countOf(m[1]), parseOptions(m[2]!)); return { fixed, choices }; }
|
||||
|
||||
// Trailing chooser after the fixed skills: ", and either X, Y, or Z" /
|
||||
// ", plus your choice of one between X or Y" / " plus one of your choice from among X or Y".
|
||||
m = /,?\s*(?:and|plus)\s+(?:your choice of\s+)?(?:(one|two|three)\s+)?(?:of your choice\s+)?(?:from among\s+|between\s+|either\s+)(.+)$/i.exec(s);
|
||||
if (m) {
|
||||
pushChoice(countOf(m[1]), parseOptions(m[2]!));
|
||||
s = s.slice(0, m.index);
|
||||
}
|
||||
return out;
|
||||
|
||||
// Remainder: plain fixed skills ("Insight, Religion" / "Religion and Deception").
|
||||
for (const token of s.split(/,|\band\b|\bplus\b/i)) {
|
||||
const key = resolve(token.trim());
|
||||
if (key && !fixed.includes(key)) fixed.push(key);
|
||||
}
|
||||
return { fixed, choices };
|
||||
}
|
||||
|
||||
/** Skill proficiencies granted by racial traits ("proficiency in the X skill"). */
|
||||
@@ -56,3 +145,12 @@ export function parseTraitSkills(traits: string, resolve: (n: string) => string
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* skillRanks key for a PF2e Lore grant: "Scribing Lore" → "lore:scribing".
|
||||
* PF2e backgrounds train one core skill plus one Lore; the Lore doesn't map to
|
||||
* the 16 core skills, so it's persisted under a namespaced custom key instead.
|
||||
*/
|
||||
export function loreSkillKey(name: string): string {
|
||||
return `lore:${name.replace(/\s+lore\s*$/i, '').trim().toLowerCase().replace(/\s+/g, '-')}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalize5eSpellOpt, normalizePf2eSpellOpt, maxOfferedSpellLevel, spellMatchesClass } from './spells';
|
||||
|
||||
describe('normalizePf2eSpellOpt', () => {
|
||||
it('maps Cantrip-trait spells to level 0 regardless of printed rank', () => {
|
||||
const s = normalizePf2eSpellOpt({ name: 'Electric Arc', level: 1, trait: ['Cantrip', 'Electricity'], tradition: ['Arcane', 'Primal'] });
|
||||
expect(s.level).toBe(0);
|
||||
expect(s.traditions).toEqual(['arcane', 'primal']);
|
||||
});
|
||||
it('keeps leveled spells at their rank (incl. rank 10)', () => {
|
||||
expect(normalizePf2eSpellOpt({ name: 'Wish', level: 10, tradition: ['Arcane'] }).level).toBe(10);
|
||||
expect(normalizePf2eSpellOpt({ name: 'Heal', level: '1', tradition: ['Divine'] }).level).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalize5eSpellOpt', () => {
|
||||
it('keeps the class list lower-cased', () => {
|
||||
const s = normalize5eSpellOpt({ name: 'Acid Arrow', level_int: 2, school: 'Evocation', dnd_class: 'Druid, Wizard' });
|
||||
expect(s.classes).toEqual(['druid', 'wizard']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxOfferedSpellLevel', () => {
|
||||
it('caps at the top slot rank', () => {
|
||||
expect(maxOfferedSpellLevel('5e', [{ level: 1 }, { level: 2 }])).toBe(2);
|
||||
});
|
||||
it('reaches rank 10 for a level-20 PF2e caster', () => {
|
||||
const slots = Array.from({ length: 10 }, (_, i) => ({ level: i + 1 }));
|
||||
expect(maxOfferedSpellLevel('pf2e', slots)).toBe(10);
|
||||
});
|
||||
it('caps 5e at 9 even with bogus slot data', () => {
|
||||
expect(maxOfferedSpellLevel('5e', [{ level: 11 }])).toBe(9);
|
||||
});
|
||||
it('uses pact slots for warlocks (no regular slots)', () => {
|
||||
expect(maxOfferedSpellLevel('5e', [], { level: 3 })).toBe(3);
|
||||
});
|
||||
it('offers cantrips only when the class has no slots yet', () => {
|
||||
expect(maxOfferedSpellLevel('5e', [])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spellMatchesClass', () => {
|
||||
it('filters 5e spells by dnd_class', () => {
|
||||
const s = normalize5eSpellOpt({ name: 'Acid Arrow', level_int: 2, dnd_class: 'Druid, Wizard' });
|
||||
expect(spellMatchesClass(s, '5e', 'Wizard')).toBe(true);
|
||||
expect(spellMatchesClass(s, '5e', 'Cleric')).toBe(false);
|
||||
});
|
||||
it('keeps 5e spells with no class data', () => {
|
||||
expect(spellMatchesClass({ name: 'Mystery', level: 1, meta: '' }, '5e', 'Wizard')).toBe(true);
|
||||
});
|
||||
it('filters PF2e spells by the class tradition', () => {
|
||||
const arc = normalizePf2eSpellOpt({ name: 'Electric Arc', level: 1, trait: ['Cantrip'], tradition: ['Arcane', 'Primal'] });
|
||||
expect(spellMatchesClass(arc, 'pf2e', 'Wizard')).toBe(true);
|
||||
expect(spellMatchesClass(arc, 'pf2e', 'Cleric')).toBe(false);
|
||||
});
|
||||
it('keeps focus spells whose traits carry the class name', () => {
|
||||
const comp = normalizePf2eSpellOpt({ name: 'Allegro', level: 7, trait: ['Bard', 'Cantrip', 'Composition'] });
|
||||
expect(comp.level).toBe(0);
|
||||
expect(spellMatchesClass(comp, 'pf2e', 'Bard')).toBe(true);
|
||||
expect(spellMatchesClass(comp, 'pf2e', 'Wizard')).toBe(false);
|
||||
});
|
||||
it('does not filter tradition-flexible classes (Sorcerer)', () => {
|
||||
const arc = normalizePf2eSpellOpt({ name: 'Fireball', level: 3, tradition: ['Arcane'] });
|
||||
expect(spellMatchesClass(arc, 'pf2e', 'Sorcerer')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
|
||||
/** One selectable spell in the wizard's picker. */
|
||||
export interface SpellOpt {
|
||||
name: string;
|
||||
/** 0 = cantrip in both systems (PF2e cantrips are stored at level 0 so they never cost slots). */
|
||||
level: number;
|
||||
meta: string;
|
||||
/** 5e: classes whose spell lists carry it (lower-case, from dnd_class). */
|
||||
classes?: string[];
|
||||
/** PF2e: traditions (lower-case: arcane/divine/occult/primal). */
|
||||
traditions?: string[];
|
||||
/** PF2e: traits (lower-case) — carry the class name for focus/composition spells. */
|
||||
traits?: string[];
|
||||
}
|
||||
|
||||
export function normalize5eSpellOpt(s: { name: string; level_int?: number; school?: string; dnd_class?: string }): SpellOpt {
|
||||
const classes = String(s.dnd_class ?? '').split(',').map((c) => c.trim().toLowerCase()).filter(Boolean);
|
||||
return { name: s.name, level: s.level_int ?? 0, meta: s.school ?? '', ...(classes.length ? { classes } : {}) };
|
||||
}
|
||||
|
||||
export function normalizePf2eSpellOpt(s: Record<string, unknown>): SpellOpt {
|
||||
const traits = Array.isArray(s.trait) ? (s.trait as unknown[]).map((t) => String(t).toLowerCase()) : [];
|
||||
const traditions = Array.isArray(s.tradition) ? (s.tradition as unknown[]).map((t) => String(t).toLowerCase()) : [];
|
||||
// AoN lists a cantrip's printed rank as `level` — the Cantrip trait is what
|
||||
// marks it castable at will. Store cantrips at level 0 so they never cost slots.
|
||||
const level = traits.includes('cantrip') ? 0 : Number(s.level) || 0;
|
||||
const meta = Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '';
|
||||
return {
|
||||
name: String(s.name), level, meta,
|
||||
...(traditions.length ? { traditions } : {}),
|
||||
...(traits.length ? { traits } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** PF2e casters with a FIXED tradition; bloodline/patron/eidolon classes are absent (no filter). */
|
||||
export const PF2E_CLASS_TRADITION: Record<string, 'arcane' | 'divine' | 'occult' | 'primal'> = {
|
||||
animist: 'divine', bard: 'occult', cleric: 'divine', druid: 'primal',
|
||||
magus: 'arcane', oracle: 'divine', psychic: 'occult', wizard: 'arcane',
|
||||
};
|
||||
|
||||
/**
|
||||
* Highest spell level the picker should offer: the class's actual top slot
|
||||
* (incl. warlock pact slots) at this level. 0 = cantrips only (e.g. a level-1
|
||||
* 5e half-caster has no slots yet).
|
||||
*/
|
||||
export function maxOfferedSpellLevel(
|
||||
system: SystemId,
|
||||
slots: readonly { level: number }[],
|
||||
pact?: { level: number } | undefined,
|
||||
): number {
|
||||
const sysMax = system === 'pf2e' ? 10 : 9;
|
||||
const top = Math.max(0, ...slots.map((s) => s.level), pact?.level ?? 0);
|
||||
return Math.min(sysMax, top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this spell on the selected class's list? 5e uses the data's dnd_class
|
||||
* list; PF2e uses the class's tradition (focus spells carry the class name as
|
||||
* a trait instead). Spells the data can't place are kept, never hidden.
|
||||
*/
|
||||
export function spellMatchesClass(s: SpellOpt, system: SystemId, className: string): boolean {
|
||||
const cls = className.trim().toLowerCase();
|
||||
if (!cls) return true;
|
||||
if (system === '5e') return !s.classes?.length || s.classes.includes(cls);
|
||||
const tradition = PF2E_CLASS_TRADITION[cls];
|
||||
if (!tradition) return true;
|
||||
if (s.traditions?.length) return s.traditions.includes(tradition);
|
||||
if (s.traits?.length) return s.traits.includes(cls);
|
||||
return true;
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export function useAllPcs(): Character[] {
|
||||
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
|
||||
}
|
||||
|
||||
export function useCharacter(id: string): Character | undefined {
|
||||
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
|
||||
/**
|
||||
* undefined = still loading; null = definitively not found. Mapping the miss to
|
||||
* null lets pages show a real "not found" state instead of loading forever.
|
||||
*/
|
||||
export function useCharacter(id: string): Character | null | undefined {
|
||||
return useLiveQuery(async () => (await charactersRepo.get(id)) ?? null, [id], undefined);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,13 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
const [picking, setPicking] = useState(false);
|
||||
const sys = getSystem(c.system);
|
||||
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
||||
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
|
||||
// skillRanks matters here: 5e passive Perception reads the perception skill's
|
||||
// proficiency from it (omitting it silently dropped the proficiency bonus).
|
||||
const rulesInput: CharacterRulesInput = {
|
||||
level: c.level,
|
||||
abilities: c.abilities,
|
||||
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
if (name.trim() === '') return;
|
||||
@@ -49,10 +55,10 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
New attack
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
|
||||
</label>
|
||||
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>From weapon…</Button>}
|
||||
<Button variant="secondary" onClick={() => setPicking(true)}>From weapon…</Button>
|
||||
<Button variant="primary" onClick={add}>Add</Button>
|
||||
</div>
|
||||
{picking && <WeaponPickerModal onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
|
||||
{picking && <WeaponPickerModal system={c.system} onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
|
||||
|
||||
{c.attacks.length === 0 ? (
|
||||
<p className="text-sm text-muted">No attacks defined.</p>
|
||||
@@ -62,6 +68,8 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
const result = sys.weaponAttack(rulesInput, {
|
||||
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus,
|
||||
damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
|
||||
...(a.agile !== undefined ? { agile: a.agile } : {}),
|
||||
...(a.striking !== undefined ? { striking: a.striking } : {}),
|
||||
});
|
||||
return (
|
||||
<li key={a.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||
@@ -74,6 +82,20 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
</Select>
|
||||
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
|
||||
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
|
||||
{c.system === 'pf2e' && (
|
||||
<>
|
||||
<label className="flex items-center gap-1 text-xs text-muted" title="Agile trait — multiple-attack penalty −4/−8 instead of −5/−10">
|
||||
<input type="checkbox" checked={a.agile ?? false} onChange={(e) => patch(a.id, { agile: e.target.checked })} aria-label={`${a.name} agile`} />
|
||||
agile
|
||||
</label>
|
||||
<Select className="w-auto py-1 text-xs" value={String(a.striking ?? 1)} onChange={(e) => patch(a.id, { striking: Number(e.target.value) })} aria-label={`${a.name} striking rune`}>
|
||||
<option value="1">no rune</option>
|
||||
<option value="2">striking</option>
|
||||
<option value="3">greater striking</option>
|
||||
<option value="4">major striking</option>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1 rounded bg-elevated px-2 py-1 text-sm">
|
||||
<button
|
||||
onClick={() => rollAndShow({ expression: `1d20${formatModifier(result.toHit)}`, label: `${a.name} — attack` })}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { collectChoices, collectFeatures } from '@/lib/rules';
|
||||
import type { ClassEntry } from '@/lib/schemas';
|
||||
import { collectChoices, collectFeatures, getSystem, type ProficiencyRank } from '@/lib/rules';
|
||||
import type { Character, ClassEntry } from '@/lib/schemas';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { SheetSection, type SectionProps } from './common';
|
||||
@@ -24,8 +24,26 @@ export function ClassFeaturesSection({ c, update }: SectionProps) {
|
||||
const setVal = (key: string, idx: number, value: string) => {
|
||||
const cur = [...getVals(key)];
|
||||
while (cur.length <= idx) cur.push('');
|
||||
const prev = cur[idx] ?? '';
|
||||
cur[idx] = value;
|
||||
update({ choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] });
|
||||
const patch: Partial<Character> = { choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] };
|
||||
// 5e Expertise picks aren't just recorded — they raise the skill to expert so the
|
||||
// doubled proficiency actually lands in the math (same rule as LevelUpModal).
|
||||
if (key.endsWith(':expertise')) {
|
||||
const sys = getSystem(c.system);
|
||||
const resolve = (label: string) => {
|
||||
const want = label.trim().toLowerCase();
|
||||
return sys.skills.find((s) => s.label.toLowerCase() === want || s.key === want)?.key;
|
||||
};
|
||||
const ranks = { ...c.skillRanks } as Record<string, ProficiencyRank>;
|
||||
const oldKey = prev ? resolve(prev) : undefined;
|
||||
const newKey = value ? resolve(value) : undefined;
|
||||
// Re-picking reverts the previous skill to plain proficiency (expertise implies trained).
|
||||
if (oldKey && oldKey !== newKey && ranks[oldKey] === 'expert') ranks[oldKey] = 'trained';
|
||||
if (newKey) ranks[newKey] = 'expert';
|
||||
patch.skillRanks = ranks;
|
||||
}
|
||||
update(patch);
|
||||
};
|
||||
|
||||
const pendingTotal = choices.reduce((n, ch) => n + Math.max(0, ch.count - getVals(ch.key).filter(Boolean).length), 0);
|
||||
|
||||
@@ -36,6 +36,14 @@ export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial
|
||||
|
||||
const commit = (next: ClassEntry[]) => {
|
||||
const mirror = normalizeClassMirror({ classes: next });
|
||||
// Only reconcile slots when every class name is one we can derive slots for.
|
||||
// A mid-rename ("Wiz") or an off-list class (Artificer, homebrew) must not
|
||||
// wipe the character's existing slot table with an empty derivation.
|
||||
const anyUnknown = next.some((e) => e.className && !names.includes(e.className));
|
||||
if (anyUnknown) {
|
||||
update({ classes: next, ...mirror });
|
||||
return;
|
||||
}
|
||||
const { slots, pact } = reconcileSlots(c.spellcasting, dnd5eClassesSlots(next));
|
||||
const spellcasting: Spellcasting = { ...c.spellcasting, slots };
|
||||
if (pact) spellcasting.pact = pact; else delete spellcasting.pact;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useState } from 'react';
|
||||
import { Minus, Plus, Skull, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue } from '@/lib/mechanics';
|
||||
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, rollDeathSave } from '@/lib/mechanics';
|
||||
import { rollAndShow } from '@/lib/useRoll';
|
||||
import type { Degree } from '@/lib/dice/check';
|
||||
import type { Condition } from '@/lib/schemas/common';
|
||||
import { SheetSection, type SectionProps } from './common';
|
||||
@@ -76,11 +77,12 @@ function Pf2eDefenses({ c, update }: SectionProps) {
|
||||
<Field label="Doomed">
|
||||
<NumberField className="w-20" value={d.doomed} min={0} max={3} onChange={(v) => setDefenses({ doomed: v })} aria-label="Doomed value" />
|
||||
</Field>
|
||||
<Field label="Hero Points">
|
||||
<Field label="Hero Points (max 3)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
|
||||
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
|
||||
{/* PF2e RAW: a character can never have more than 3 Hero Points. */}
|
||||
<Button size="icon" variant="ghost" disabled={d.heroPoints >= 3} onClick={() => setDefenses({ heroPoints: Math.min(3, d.heroPoints + 1) })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@@ -124,6 +126,16 @@ function Pf2eDefenses({ c, update }: SectionProps) {
|
||||
export function DefensesSection({ c, update }: SectionProps) {
|
||||
const d = c.defenses;
|
||||
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
|
||||
// 5e death save: rolled ONLY from the explicit button below (never automatic).
|
||||
// The d20 face drives the full PHB outcome via the tested rollDeathSave kernel.
|
||||
const [deathMsg, setDeathMsg] = useState<string | null>(null);
|
||||
const doRollDeathSave = () => {
|
||||
const r = rollAndShow({ expression: '1d20', label: `${c.name} — death save` });
|
||||
if (!r) return;
|
||||
const res = rollDeathSave(c, r.total);
|
||||
update(res.patch);
|
||||
setDeathMsg(`Rolled ${r.total} — ${res.log}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetSection title="Status & Defenses">
|
||||
@@ -139,6 +151,24 @@ export function DefensesSection({ c, update }: SectionProps) {
|
||||
Failures
|
||||
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
|
||||
</span>
|
||||
{d.deathSaves.failures >= 3 && (
|
||||
<span className="flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-xs font-semibold text-danger"><Skull size={12} aria-hidden /> Dead</span>
|
||||
)}
|
||||
{d.deathSaves.failures < 3 && d.deathSaves.successes >= 3 && (
|
||||
<span className="rounded bg-success/15 px-1.5 py-0.5 text-xs font-semibold text-success">Stable</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={doRollDeathSave}
|
||||
disabled={c.hp.current > 0 || d.deathSaves.failures >= 3 || d.deathSaves.successes >= 3}
|
||||
title={c.hp.current > 0 ? 'Death saves are rolled at 0 HP' : 'Roll 1d20: 10+ succeeds, nat 1 counts as two failures, nat 20 regains 1 HP'}
|
||||
>
|
||||
Roll death save
|
||||
</Button>
|
||||
{deathMsg && <span className="text-xs text-muted" aria-live="polite">{deathMsg}</span>}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Inspiration">
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadFeats5e } from '@/lib/compendium';
|
||||
import type { Feat5e } from '@/lib/compendium/types';
|
||||
import { loadFeats5e, loadPf2e } from '@/lib/compendium';
|
||||
import type { CompendiumEntry, Feat5e } from '@/lib/compendium/types';
|
||||
import { sourceLabel } from '@/lib/compendium/mpmb';
|
||||
import { newId } from '@/lib/ids';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Feat } from '@/lib/schemas';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
/** Browse the 5e feat compendium (PHB/XGtE/TCE) and add one as a tracked feat. */
|
||||
export function FeatPickerModal({ onPick, onClose }: { onPick: (f: Feat) => void; onClose: () => void }) {
|
||||
const [feats, setFeats] = useState<Feat5e[]>([]);
|
||||
/** One searchable feat row, unified across the 5e and PF2e datasets. */
|
||||
interface FeatRow {
|
||||
key: string;
|
||||
name: string;
|
||||
source: string;
|
||||
/** small right-hand tag (source book, PF2e feat level) */
|
||||
tag: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function row5e(f: Feat5e): FeatRow {
|
||||
const src = f.source?.[0]?.source ? sourceLabel(f.source[0].source) : '';
|
||||
return { key: f.name, name: f.name, source: src, tag: src, description: f.description ?? '' };
|
||||
}
|
||||
|
||||
function rowPf2e(e: CompendiumEntry): FeatRow {
|
||||
const level = Number(e.level) || 0;
|
||||
return {
|
||||
key: String(e.slug ?? e.id ?? e.name),
|
||||
name: String(e.name),
|
||||
source: String(e.primary_source ?? ''),
|
||||
tag: level ? `Feat ${level}` : String(e.primary_source ?? ''),
|
||||
description: String(e.summary ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** Browse the system's feat compendium and add one as a tracked feat. */
|
||||
export function FeatPickerModal({ system = '5e', onPick, onClose }: { system?: SystemId; onPick: (f: Feat) => void; onClose: () => void }) {
|
||||
const [feats, setFeats] = useState<FeatRow[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
void loadFeats5e().then((f) => on && setFeats([...f].sort((a, b) => a.name.localeCompare(b.name))));
|
||||
const load = system === 'pf2e'
|
||||
? loadPf2e('feats').then((fs) => fs.map(rowPf2e))
|
||||
: loadFeats5e().then((fs) => fs.map(row5e));
|
||||
void load.then((rows) => on && setFeats(rows.sort((a, b) => a.name.localeCompare(b.name))));
|
||||
return () => { on = false; };
|
||||
}, []);
|
||||
}, [system]);
|
||||
const results = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
return feats.filter((f) => !s || f.name.toLowerCase().includes(s)).slice(0, 80);
|
||||
}, [feats, q]);
|
||||
|
||||
const pick = (f: Feat5e) => {
|
||||
onPick({
|
||||
id: newId(),
|
||||
name: f.name,
|
||||
source: f.source?.[0]?.source ? sourceLabel(f.source[0].source) : '',
|
||||
description: f.description ?? '',
|
||||
});
|
||||
const pick = (f: FeatRow) => {
|
||||
onPick({ id: newId(), name: f.name, source: f.source, description: f.description });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -39,11 +64,11 @@ export function FeatPickerModal({ onPick, onClose }: { onPick: (f: Feat) => void
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{feats.length === 0 && <p className="text-sm text-muted">Loading…</p>}
|
||||
{results.map((f) => (
|
||||
<button key={f.name} onClick={() => pick(f)}
|
||||
<button key={f.key} onClick={() => pick(f)}
|
||||
className="block w-full rounded-md border border-line bg-surface px-2 py-1.5 text-left hover:border-accent/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-ink">{f.name}</span>
|
||||
{f.source?.[0]?.source && <span className="shrink-0 text-[10px] text-muted">{sourceLabel(f.source[0].source)}</span>}
|
||||
{f.tag && <span className="shrink-0 text-[10px] text-muted">{f.tag}</span>}
|
||||
</div>
|
||||
{f.description && <p className="line-clamp-2 text-[11px] text-muted">{f.description}</p>}
|
||||
</button>
|
||||
|
||||
@@ -29,10 +29,10 @@ export function FeatsSection({ c, update }: SectionProps) {
|
||||
Add feat / feature
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
|
||||
</label>
|
||||
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>Browse feats…</Button>}
|
||||
<Button variant="secondary" onClick={() => setPicking(true)}>Browse feats…</Button>
|
||||
<Button variant="primary" onClick={add}>Add</Button>
|
||||
</div>
|
||||
{picking && <FeatPickerModal onPick={(feat) => update({ feats: [...c.feats, feat] })} onClose={() => setPicking(false)} />}
|
||||
{picking && <FeatPickerModal system={c.system} onPick={(feat) => update({ feats: [...c.feats, feat] })} onClose={() => setPicking(false)} />}
|
||||
|
||||
{c.feats.length === 0 ? (
|
||||
<p className="text-sm text-muted">No feats or features tracked yet.</p>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { FeatPickerModal } from './FeatPickerModal';
|
||||
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
||||
import { incAllowed, conRetroHpBonus } from './levelUpMath';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
|
||||
@@ -39,15 +40,23 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
const levelingName = is5e ? (levelClass === NEW ? newClassName : levelClass) : character.className;
|
||||
const classCurrentLevel = is5e ? (classList.find((e) => e.className === levelingName)?.level ?? 0) : character.level;
|
||||
|
||||
const total = totalLevel(character);
|
||||
const plan = useMemo(
|
||||
() => planLevelUp(character.system, levelingName, classCurrentLevel, conMod),
|
||||
[character.system, levelingName, classCurrentLevel, conMod],
|
||||
() => planLevelUp(character.system, levelingName, classCurrentLevel, conMod, Math.min(20, total + 1)),
|
||||
[character.system, levelingName, classCurrentLevel, conMod, total],
|
||||
);
|
||||
const sys = getSystem(character.system);
|
||||
const def = getClassDef(character.system, levelingName);
|
||||
const total = totalLevel(character);
|
||||
|
||||
const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average');
|
||||
// Rolled HP is rolled by an explicit button press (never silently inside Apply)
|
||||
// so the player sees the die result before committing.
|
||||
const [rolledHp, setRolledHp] = useState<{ face: number; total: number } | null>(null);
|
||||
useEffect(() => { setRolledHp(null); }, [hpMethod, levelingName]);
|
||||
const rollHp = () => {
|
||||
const face = rollDice(`1d${plan.hitDie}`, createRng()).total;
|
||||
setRolledHp({ face, total: Math.max(1, face + conMod) });
|
||||
};
|
||||
const asi = plan.choices.find((c) => c.kind === 'asi');
|
||||
const boosts = plan.choices.find((c) => c.kind === 'boosts');
|
||||
const skillInc = plan.choices.find((c) => c.kind === 'skill-increase');
|
||||
@@ -86,7 +95,9 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
// How many of a choice the character has already recorded (subclass also counts the
|
||||
// class entry's own subclass field, set before the choices array existed).
|
||||
const resolvedCount = (key: string) => {
|
||||
const fromChoices = character.choices.find((c) => c.key === key)?.values.length ?? 0;
|
||||
// Only non-empty values count as resolved — blank placeholders recorded by other
|
||||
// surfaces must not make a still-owed choice look answered.
|
||||
const fromChoices = character.choices.find((c) => c.key === key)?.values.filter((v) => v.trim()).length ?? 0;
|
||||
if (key.endsWith(':subclass')) {
|
||||
const cn = key.slice(0, -':subclass'.length).toLowerCase();
|
||||
return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
|
||||
@@ -140,10 +151,33 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
}, [pendingChoices]);
|
||||
|
||||
const atMax = total >= 20;
|
||||
|
||||
// 5e hard cap: an ASI can never push a score above 20.
|
||||
const asiWouldExceed = Boolean(is5e && asi && asiMode === 'asi' && (() => {
|
||||
const counts = new Map<AbilityKey, number>();
|
||||
for (const a of asiPicks) counts.set(a, (counts.get(a) ?? 0) + 1);
|
||||
return [...counts].some(([a, n]) => character.abilities[a] + n > 20);
|
||||
})());
|
||||
// PF2e rank gates: the selected skill increase must be legal at the new level.
|
||||
const skillIncCapped = Boolean(
|
||||
skillInc && skillKey && !incAllowed((character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained', plan.nextLevel),
|
||||
);
|
||||
// Nothing may be silently dropped on Apply: feat mode needs a feat, roll mode needs a roll.
|
||||
const blockReason = asiWouldExceed
|
||||
? 'An ASI can’t raise a score above 20 — adjust your picks.'
|
||||
: skillIncCapped
|
||||
? 'That skill can’t be increased yet (Master needs level 7+, Legendary 15+) — pick another.'
|
||||
: asi && asiMode === 'feat' && !featPick
|
||||
? 'Pick a feat (or switch back to +2 abilities) before applying.'
|
||||
: is5e && hpMethod === 'roll' && !rolledHp
|
||||
? 'Roll your hit die before applying.'
|
||||
: null;
|
||||
|
||||
const apply = () => {
|
||||
if (blockReason) return;
|
||||
const gain = character.system === 'pf2e'
|
||||
? plan.hpGainAverage
|
||||
: hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
|
||||
: hpMethod === 'average' ? plan.hpGainAverage : (rolledHp?.total ?? plan.hpGainAverage);
|
||||
|
||||
// Ability increases go through the build so the sheet's per-source breakdown
|
||||
// stays in sync with the totals (the build is the single source of truth).
|
||||
@@ -156,6 +190,13 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`));
|
||||
}
|
||||
|
||||
// A CON increase applies retroactively: +1 max HP per character level per point
|
||||
// of CON modifier gained. `gain` above used the OLD modifier, so the delta term
|
||||
// covers every level including the new one. 5e multiclass: retro scales with the
|
||||
// TOTAL character level, not the advanced class's level.
|
||||
const newTotalLevel = is5e ? Math.min(20, total + 1) : plan.nextLevel;
|
||||
const hpGain = gain + conRetroHpBonus(character.abilities.con, abilities.con, newTotalLevel);
|
||||
|
||||
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
|
||||
const subFromChoice = Object.entries(choicePicks)
|
||||
.find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim();
|
||||
@@ -175,7 +216,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
}
|
||||
|
||||
const patch: Partial<Character> = {
|
||||
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
||||
hp: { ...character.hp, max: character.hp.max + hpGain, current: character.hp.current + hpGain },
|
||||
abilities,
|
||||
...(abilityBuild ? { abilityBuild } : {}),
|
||||
classes: nextClassesFinal,
|
||||
@@ -183,7 +224,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
choices: merged,
|
||||
};
|
||||
|
||||
if (is5e) {
|
||||
// Only re-derive 5e slots when every class resolves to a known ClassDef — an
|
||||
// off-list/homebrew class derives to [] and would wipe a manually maintained
|
||||
// slot table (mirrors ClassesEditor.commit's anyUnknown guard).
|
||||
const known5eNames = getClassNames('5e');
|
||||
const anyUnknown5e = is5e && nextClassesFinal.some((e) => e.className && !known5eNames.includes(e.className));
|
||||
if (is5e && !anyUnknown5e) {
|
||||
// Re-derive combined spell slots, preserving current values.
|
||||
const derived = dnd5eClassesSlots(nextClassesFinal);
|
||||
const slots = derived.slots.map((r) => {
|
||||
@@ -194,13 +240,25 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
|
||||
else delete spellcasting.pact;
|
||||
patch.spellcasting = spellcasting;
|
||||
} else if (plan.slots) {
|
||||
patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
|
||||
} else if (!is5e && plan.slots) {
|
||||
// Preserve spent slots on the pf2e path too (same rule as the 5e branch above):
|
||||
// leveling up mid-day must not refill your expended slots.
|
||||
const slots = plan.slots.map((r) => {
|
||||
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
|
||||
return { ...r, current: ex ? Math.min(r.max, ex.current) : r.max };
|
||||
});
|
||||
const pact = plan.pact
|
||||
? { ...plan.pact, current: Math.min(plan.pact.max, character.spellcasting.pact?.current ?? plan.pact.max) }
|
||||
: undefined;
|
||||
patch.spellcasting = { ...character.spellcasting, slots, ...(pact ? { pact } : {}) };
|
||||
}
|
||||
|
||||
if (skillInc && skillKey) {
|
||||
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
|
||||
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
|
||||
// Rank gates re-checked here (blockReason already blocks Apply on a capped pick).
|
||||
if (incAllowed(current, plan.nextLevel)) {
|
||||
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
|
||||
}
|
||||
}
|
||||
// 5e Expertise picks aren't just recorded — they raise the skill rank to expert
|
||||
// so the doubled proficiency actually lands in the math.
|
||||
@@ -221,7 +279,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
const newTotal = Math.min(20, total + 1);
|
||||
const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice');
|
||||
patch.resources = i >= 0
|
||||
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.ceil(newTotal / 2) } : r))
|
||||
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.max(1, Math.floor(newTotal / 2)) } : r))
|
||||
: [...character.resources, hitDiceResource(newTotal)];
|
||||
}
|
||||
if (asi && asiMode === 'feat' && featPick) {
|
||||
@@ -243,8 +301,9 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
className="max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
{blockReason && !atMax && <span className="mr-auto self-center text-xs text-warning">{blockReason}</span>}
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={atMax} onClick={apply}>Apply</Button>
|
||||
<Button variant="primary" disabled={atMax || Boolean(blockReason)} onClick={apply}>Apply</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -274,12 +333,26 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
<section>
|
||||
<h3 className="mb-1 smallcaps">Hit points</h3>
|
||||
{character.system === '5e' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
||||
<option value="average">Average (+{plan.hpGainAverage})</option>
|
||||
<option value="roll">Roll d{plan.hitDie}</option>
|
||||
</Select>
|
||||
<span className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})</span>
|
||||
{hpMethod === 'roll' ? (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" onClick={rollHp}>{rolledHp ? 'Reroll' : `Roll d${plan.hitDie}`}</Button>
|
||||
{rolledHp ? (
|
||||
<span className="text-sm text-muted">
|
||||
Rolled <span className="font-mono font-medium text-ink">{rolledHp.face}</span>
|
||||
{' '}→ gain <span className="font-medium text-ink">+{rolledHp.total}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted">({hpPreview})</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (class {plan.hitDie} + CON {conMod >= 0 ? '+' : ''}{conMod})</p>
|
||||
@@ -303,7 +376,14 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
<div className="flex gap-2">
|
||||
{[0, 1].map((i) => (
|
||||
<Select key={i} aria-label={`ASI ability ${i + 1}`} value={asiPicks[i]} onChange={(e) => setAsiPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
||||
{ABILITIES.map((a) => <option key={a} value={a}>{ABILITY_ABBR[a]} (+1)</option>)}
|
||||
{ABILITIES.map((a) => {
|
||||
const otherPicks = asiPicks.filter((p, j) => p === a && j !== i).length;
|
||||
return (
|
||||
<option key={a} value={a} disabled={character.abilities[a] + otherPicks + 1 > 20}>
|
||||
{ABILITY_ABBR[a]} (+1){character.abilities[a] >= 20 ? ' — at 20' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
))}
|
||||
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
|
||||
@@ -343,9 +423,11 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
||||
{sys.skills.map((s) => {
|
||||
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
||||
return <option key={s.key} value={s.key}>{s.label}: {cur} → {bumpRank(cur)}</option>;
|
||||
const ok = incAllowed(cur, plan.nextLevel);
|
||||
return <option key={s.key} value={s.key} disabled={!ok}>{s.label}: {cur} → {ok ? bumpRank(cur) : '(capped)'}</option>;
|
||||
})}
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted">Master needs level 7+, Legendary 15+.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
if (next > maxRank) return;
|
||||
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
||||
};
|
||||
// Warlock pact magic: a separate short-rest pool the caster spends alongside slots.
|
||||
const patchPact = (p: Partial<NonNullable<typeof sc.pact>>) =>
|
||||
sc.pact && update({ spellcasting: { ...sc, pact: { ...sc.pact, ...p } } });
|
||||
const addPact = () => update({ spellcasting: { ...sc, pact: { level: 1, max: 1, current: 1 } } });
|
||||
const removePact = () => {
|
||||
const { pact: _pact, ...rest } = sc;
|
||||
update({ spellcasting: rest });
|
||||
};
|
||||
|
||||
const addSpell = () => {
|
||||
if (spellName.trim() === '') return;
|
||||
@@ -127,8 +135,11 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="smallcaps">Spell slots</span>
|
||||
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
||||
{c.system === '5e' && !sc.pact && (
|
||||
<Button size="sm" variant="ghost" onClick={addPact} title="Track a Warlock pact-magic pool (refreshes on a short rest)">+ pact slots</Button>
|
||||
)}
|
||||
</div>
|
||||
{sc.slots.length === 0 ? (
|
||||
{sc.slots.length === 0 && !sc.pact ? (
|
||||
<p className="text-sm text-muted">No spell slots configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -142,6 +153,9 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sc.pact && (
|
||||
<PactSlotCard pact={sc.pact} maxRank={maxRank} onPatch={patchPact} onRemove={removePact} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
|
||||
@@ -212,6 +226,30 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Warlock pact-magic pool: level + current/max, spendable like a slot row.
|
||||
* Casting consumes it (castSpell offers the pact level); this is the manual mirror. */
|
||||
function PactSlotCard({ pact, maxRank, onPatch, onRemove }: {
|
||||
pact: { level: number; max: number; current: number };
|
||||
maxRank: number;
|
||||
onPatch: (p: Partial<{ level: number; max: number; current: number }>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-accent/40 bg-panel px-2 py-1 text-center">
|
||||
<div className="flex items-center justify-center gap-1 text-[10px] uppercase text-muted">
|
||||
<span title="Pact Magic — all slots are the same level and refresh on a short rest">Pact · Lv</span>
|
||||
<NumberField className="w-10" value={pact.level} min={1} max={maxRank} onChange={(level) => onPatch({ level })} aria-label="Pact slot level" />
|
||||
<Button size="icon" variant="ghost" className="h-5 w-5 text-danger" onClick={onRemove} aria-label="Remove pact slots"><X size={12} aria-hidden /></Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onPatch({ current: Math.max(0, pact.current - 1) })} aria-label="Spend pact slot"><Minus size={14} aria-hidden /></Button>
|
||||
<span className="text-sm"><span className="font-medium text-ink">{pact.current}</span>/<NumberField className="inline-block w-12" value={pact.max} min={0} onChange={(max) => onPatch({ max, current: Math.min(max, pact.current) })} aria-label="Pact max slots" /></span>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onPatch({ current: Math.min(pact.max, pact.current + 1) })} aria-label="Regain pact slot"><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PHB multiclass spell-slot calculator: enter your class levels by caster type
|
||||
* and it fills the slot table correctly (full ×1, half ÷2, third ÷3). */
|
||||
function MulticlassSlots({ onApply }: { onApply: (slots: { level: number; max: number; current: number }[]) => void }) {
|
||||
|
||||
@@ -1,42 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadWeapons5e } from '@/lib/compendium';
|
||||
import type { Weapon5e } from '@/lib/compendium/types';
|
||||
import { loadWeapons5e, loadPf2e } from '@/lib/compendium';
|
||||
import type { CompendiumEntry, Weapon5e } from '@/lib/compendium/types';
|
||||
import { newId } from '@/lib/ids';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Attack } from '@/lib/schemas';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
/** Default attack ability for a weapon: ranged/finesse → DEX, else STR. */
|
||||
function defaultAbility(w: Weapon5e): Attack['ability'] {
|
||||
/** One searchable weapon row, unified across the 5e and PF2e datasets. */
|
||||
interface WeaponRow {
|
||||
key: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
toAttack: () => Attack;
|
||||
}
|
||||
|
||||
/** Default attack ability for a 5e weapon: ranged/finesse → DEX, else STR. */
|
||||
function defaultAbility5e(w: Weapon5e): Attack['ability'] {
|
||||
const props = (w.properties ?? []).map((p) => p.toLowerCase());
|
||||
const ranged = /ranged/i.test(w.category ?? '') || props.some((p) => p.startsWith('ammunition') || p.startsWith('thrown'));
|
||||
if (ranged || props.some((p) => p.startsWith('finesse'))) return 'dex';
|
||||
return 'str';
|
||||
}
|
||||
|
||||
function toAttack(w: Weapon5e): Attack {
|
||||
function row5e(w: Weapon5e): WeaponRow {
|
||||
return {
|
||||
id: newId(),
|
||||
key: w.slug,
|
||||
name: w.name,
|
||||
ability: defaultAbility(w),
|
||||
rank: 'trained',
|
||||
damageDice: w.damage_dice ?? '1d4',
|
||||
damageType: w.damage_type ?? '',
|
||||
itemBonus: 0,
|
||||
addAbilityToDamage: true,
|
||||
detail: `${w.damage_dice ?? ''} ${w.damage_type ?? ''}${(w.properties ?? []).some((p) => /finesse/i.test(p)) ? ' · finesse' : ''}`.trim(),
|
||||
toAttack: () => ({
|
||||
id: newId(),
|
||||
name: w.name,
|
||||
ability: defaultAbility5e(w),
|
||||
rank: 'trained',
|
||||
damageDice: w.damage_dice ?? '1d4',
|
||||
damageType: w.damage_type ?? '',
|
||||
itemBonus: 0,
|
||||
addAbilityToDamage: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pick a 5e weapon from the compendium; creates a ready-to-roll attack with correct dice. */
|
||||
export function WeaponPickerModal({ onPick, onClose }: { onPick: (a: Attack) => void; onClose: () => void }) {
|
||||
const [weapons, setWeapons] = useState<Weapon5e[]>([]);
|
||||
function rowPf2e(e: CompendiumEntry): WeaponRow {
|
||||
const traits = Array.isArray(e.trait) ? (e.trait as unknown[]).map((t) => String(t).toLowerCase()) : [];
|
||||
const ranged = String(e.weapon_type ?? '') === 'Ranged';
|
||||
// "damage" reads like "1d6 S"; some weapons deal flat damage ("1 P", e.g. Blowgun).
|
||||
const dice = /(\d+d\d+)/.exec(String(e.damage ?? ''))?.[1]
|
||||
?? (Number(e.damage_die) ? `1d${Number(e.damage_die)}` : /^\s*(\d+)\b/.exec(String(e.damage ?? ''))?.[1] ?? '1d6');
|
||||
const dmgType = Array.isArray(e.damage_type) ? String((e.damage_type as unknown[])[0] ?? '').toLowerCase() : '';
|
||||
const shownTraits = traits.filter((t) => t === 'agile' || t === 'finesse').join(' · ');
|
||||
return {
|
||||
key: String(e.slug ?? e.id ?? e.name),
|
||||
name: String(e.name),
|
||||
detail: `${dice} ${dmgType}${shownTraits ? ` · ${shownTraits}` : ''}${ranged ? ' · ranged' : ''}`.trim(),
|
||||
toAttack: () => ({
|
||||
id: newId(),
|
||||
name: String(e.name),
|
||||
// Finesse lets the attack roll use DEX; ranged attacks are DEX-based.
|
||||
ability: ranged || traits.includes('finesse') ? 'dex' : 'str',
|
||||
rank: 'trained',
|
||||
damageDice: dice,
|
||||
damageType: dmgType,
|
||||
itemBonus: 0,
|
||||
// PF2e ranged weapons don't add an ability modifier to damage (thrown/propulsive aside).
|
||||
addAbilityToDamage: !ranged,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pick a weapon from the system's compendium; creates a ready-to-roll attack with correct dice. */
|
||||
export function WeaponPickerModal({ system, onPick, onClose }: { system: SystemId; onPick: (a: Attack) => void; onClose: () => void }) {
|
||||
const [weapons, setWeapons] = useState<WeaponRow[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
void loadWeapons5e().then((w) => on && setWeapons([...w].sort((a, b) => a.name.localeCompare(b.name))));
|
||||
const load = system === 'pf2e'
|
||||
? loadPf2e('weapons').then((ws) => ws.map(rowPf2e))
|
||||
: loadWeapons5e().then((ws) => ws.map(row5e));
|
||||
void load.then((rows) => on && setWeapons(rows.sort((a, b) => a.name.localeCompare(b.name))));
|
||||
return () => { on = false; };
|
||||
}, []);
|
||||
}, [system]);
|
||||
const results = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
return weapons.filter((w) => !s || w.name.toLowerCase().includes(s)).slice(0, 80);
|
||||
@@ -49,10 +93,10 @@ export function WeaponPickerModal({ onPick, onClose }: { onPick: (a: Attack) =>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{weapons.length === 0 && <p className="text-sm text-muted">Loading…</p>}
|
||||
{results.map((w) => (
|
||||
<button key={w.slug} onClick={() => { onPick(toAttack(w)); onClose(); }}
|
||||
<button key={w.key} onClick={() => { onPick(w.toAttack()); onClose(); }}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md border border-line bg-surface px-2 py-1.5 text-left text-sm hover:border-accent/60">
|
||||
<span className="truncate text-ink">{w.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{w.damage_dice} {w.damage_type}{(w.properties ?? []).some((p) => /finesse/i.test(p)) ? ' · finesse' : ''}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{w.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { incAllowed, conRetroHpBonus } from './levelUpMath';
|
||||
|
||||
describe('incAllowed (PF2e skill-increase rank gates)', () => {
|
||||
it('untrained and trained can always be increased', () => {
|
||||
expect(incAllowed('untrained', 2)).toBe(true);
|
||||
expect(incAllowed('trained', 2)).toBe(true);
|
||||
});
|
||||
it('expert → master requires level 7+', () => {
|
||||
expect(incAllowed('expert', 5)).toBe(false);
|
||||
expect(incAllowed('expert', 7)).toBe(true);
|
||||
});
|
||||
it('master → legendary requires level 15+', () => {
|
||||
expect(incAllowed('master', 13)).toBe(false);
|
||||
expect(incAllowed('master', 15)).toBe(true);
|
||||
});
|
||||
it('legendary is the cap', () => {
|
||||
expect(incAllowed('legendary', 20)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conRetroHpBonus', () => {
|
||||
it('is zero when the modifier does not change', () => {
|
||||
expect(conRetroHpBonus(14, 15, 5)).toBe(0); // +2 mod both
|
||||
expect(conRetroHpBonus(10, 10, 8)).toBe(0);
|
||||
});
|
||||
it('grants +1 per character level per modifier point gained', () => {
|
||||
// PF2e Barbarian, CON 16→18 at level 5: (12+4)*5 − (12+3)*4 = 20 total gain;
|
||||
// the level's own gain with the old mod is 15, so the retro term must be 5.
|
||||
expect(conRetroHpBonus(16, 18, 5)).toBe(5);
|
||||
// 5e ASI CON 15→16 reaching total level 8: +1 mod × 8 levels.
|
||||
expect(conRetroHpBonus(15, 16, 8)).toBe(8);
|
||||
});
|
||||
it('handles a two-point modifier jump', () => {
|
||||
expect(conRetroHpBonus(13, 17, 10)).toBe(20); // +1 → +3
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { abilityModifier, type ProficiencyRank } from '@/lib/rules';
|
||||
|
||||
/**
|
||||
* PF2e skill-increase rank gates (CRB): an increase can raise a skill to master
|
||||
* only at level 7+, to legendary only at 15+; legendary is the cap.
|
||||
* (Mirrors CreationWizard's incAllowed — the wizard validates the same rule.)
|
||||
*/
|
||||
export function incAllowed(rank: ProficiencyRank, atLevel: number): boolean {
|
||||
if (rank === 'untrained' || rank === 'trained') return true;
|
||||
if (rank === 'expert') return atLevel >= 7;
|
||||
if (rank === 'master') return atLevel >= 15;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retroactive max-HP bonus when CON rises at a level-up: +1 HP per character level
|
||||
* per point of CON modifier gained (5e PHB ASI rule / PF2e CRB). `totalLevel` is the
|
||||
* total CHARACTER level after the level-up; the caller computes the new level's own
|
||||
* HP gain with the OLD modifier, so this delta covers every level including the new one.
|
||||
*/
|
||||
export function conRetroHpBonus(oldCon: number, newCon: number, totalLevel: number): number {
|
||||
return (abilityModifier(newCon) - abilityModifier(oldCon)) * totalLevel;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Cloud, CloudOff, RefreshCw, WifiOff, AlertTriangle } from 'lucide-react';
|
||||
import { cloudUsername, pushBackup, pullBackup } from '@/lib/cloud/client';
|
||||
import { pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
|
||||
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useCloudUser } from './cloudAuth';
|
||||
|
||||
/**
|
||||
* Compact shell indicator for connectivity + cloud sync. Shows "Offline" when
|
||||
@@ -15,7 +16,7 @@ export function SyncStatusIndicator() {
|
||||
const online = useConnectivityStore((s) => s.online);
|
||||
const cloudSync = useConnectivityStore((s) => s.cloudSync);
|
||||
const setOnline = useConnectivityStore((s) => s.setOnline);
|
||||
const signedIn = !!cloudUsername();
|
||||
const signedIn = !!useCloudUser();
|
||||
|
||||
useEffect(() => {
|
||||
const on = () => setOnline(true);
|
||||
@@ -64,18 +65,34 @@ export function SyncStatusIndicator() {
|
||||
function ConflictResolver() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null);
|
||||
const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
|
||||
|
||||
const acceptCloud = async () => {
|
||||
// Failures must never escape as unhandled rejections: keep the popover open,
|
||||
// show what went wrong, and leave the conflict resolvable (a retry can succeed).
|
||||
const resolve = async (fn: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
|
||||
finally { setBusy(false); setOpen(false); }
|
||||
};
|
||||
const keepMine = async () => {
|
||||
setBusy(true);
|
||||
try { await pushBackup({ force: true }); setCloudSync('saved'); }
|
||||
finally { setBusy(false); setOpen(false); }
|
||||
setErrMsg(null);
|
||||
try { await fn(); }
|
||||
catch (e) { setErrMsg(e instanceof CloudError ? e.message : 'Something went wrong — try again.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const acceptCloud = () => resolve(async () => {
|
||||
if (await pullBackup()) {
|
||||
setCloudSync('saved');
|
||||
setOpen(false);
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} else {
|
||||
// No cloud save exists — there is nothing to conflict with.
|
||||
setCloudSync('idle');
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
const keepMine = () => resolve(async () => {
|
||||
await pushBackup({ force: true });
|
||||
setCloudSync('saved');
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -89,6 +106,7 @@ function ConflictResolver() {
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void acceptCloud()}>Use cloud version (reload)</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button>
|
||||
</div>
|
||||
{errMsg && <p className="mt-2 text-xs text-danger" role="alert">{errMsg}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { cloudUsername } from '@/lib/cloud/client';
|
||||
|
||||
/**
|
||||
* Reactive view of the cloud sign-in state. `cloudUsername()` reads localStorage,
|
||||
* which React can't observe — components that cached it at mount stayed stale
|
||||
* until a full reload. The settings UI announces sign-in/out via a window event;
|
||||
* everything auth-dependent subscribes through `useCloudUser()`.
|
||||
*/
|
||||
|
||||
const AUTH_EVENT = 'ttrpg:cloud-auth-changed';
|
||||
|
||||
/** Call after any sign-in / sign-out so auth-dependent UI re-renders. */
|
||||
export function notifyCloudAuthChanged(): void {
|
||||
window.dispatchEvent(new Event(AUTH_EVENT));
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener(AUTH_EVENT, onChange);
|
||||
// 'storage' covers a sign-in/out performed in another tab of the same origin.
|
||||
window.addEventListener('storage', onChange);
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_EVENT, onChange);
|
||||
window.removeEventListener('storage', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in cloud username (null when signed out), live across sign-in/out. */
|
||||
export function useCloudUser(): string | null {
|
||||
return useSyncExternalStore(subscribe, cloudUsername);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { publishCharacter } from '@/lib/cloud/campaigns';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { useCloudLinkStore } from '@/stores/cloudLinkStore';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useCloudUser } from './cloudAuth';
|
||||
|
||||
/**
|
||||
* Delivers the "syncs on edit" half of character publishing: whenever a locally
|
||||
* edited character is linked to a cloud campaign (see cloudLinkStore), debounce a
|
||||
* re-publish of just that character. Same character id → the server upserts, so
|
||||
* this can never create duplicates. Mounted once in the app shell; no-op when
|
||||
* signed out or when nothing has been published.
|
||||
*/
|
||||
export function useCharacterPublishSync(): void {
|
||||
const user = useCloudUser();
|
||||
const characterLinks = useCloudLinkStore((s) => s.characterLinks);
|
||||
const idsKey = Object.keys(characterLinks).sort().join(',');
|
||||
|
||||
const fingerprint = useLiveQuery(async () => {
|
||||
if (!idsKey) return {} as Record<string, string>;
|
||||
const rows = await charactersRepo.getMany(idsKey.split(','));
|
||||
return Object.fromEntries(rows.map((c) => [c.id, c.updatedAt]));
|
||||
}, [idsKey], undefined);
|
||||
|
||||
const dirty = useRef(new Set<string>());
|
||||
const flush = useDebouncedCallback(() => {
|
||||
const ids = [...dirty.current];
|
||||
dirty.current.clear();
|
||||
const links = useCloudLinkStore.getState().characterLinks;
|
||||
void (async () => {
|
||||
for (const id of ids) {
|
||||
const cloudCampaignId = links[id];
|
||||
const c = await charactersRepo.get(id);
|
||||
if (!cloudCampaignId || !c) continue;
|
||||
try {
|
||||
await publishCharacter(cloudCampaignId, { id: c.id, name: c.name, data: JSON.stringify(c) });
|
||||
} catch {
|
||||
// offline / expired session / unpublished remotely — the next edit retries;
|
||||
// never toast per keystroke.
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
|
||||
// Skip the initial load; push only characters whose updatedAt actually moved.
|
||||
const prev = useRef<Record<string, string> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (fingerprint === undefined) return;
|
||||
if (prev.current === undefined) { prev.current = fingerprint; return; }
|
||||
const before = prev.current;
|
||||
prev.current = fingerprint;
|
||||
if (!user) return;
|
||||
for (const [id, updatedAt] of Object.entries(fingerprint)) {
|
||||
// A just-linked character (no `before` entry) was published moments ago — skip it.
|
||||
if (before[id] !== undefined && before[id] !== updatedAt) dirty.current.add(id);
|
||||
}
|
||||
if (dirty.current.size > 0) flush();
|
||||
}, [user, fingerprint, flush]);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas';
|
||||
import type { Campaign, Character, Combatant, CombatantDamageDefenses, Condition, Encounter } from '@/lib/schemas';
|
||||
import { encountersRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { createRng } from '@/lib/rng';
|
||||
@@ -26,9 +26,10 @@ import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
||||
import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
|
||||
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||
import { useConditionGlossary } from './useConditionGlossary';
|
||||
import { isConditionImmune } from './conditionImmunity';
|
||||
import {
|
||||
addCombatant,
|
||||
applyDamage,
|
||||
damageOutcome,
|
||||
applyHealing,
|
||||
applyInitiatives,
|
||||
isMassiveDamageDeath,
|
||||
@@ -57,11 +58,19 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
const glossary = useConditionGlossary(campaign.system);
|
||||
|
||||
const undoStack = useRef<Encounter[]>([]);
|
||||
// The stack belongs to ONE encounter — undoing into a different fight would
|
||||
// silently revert it with another encounter's history.
|
||||
const undoFor = useRef(encounter.id);
|
||||
if (undoFor.current !== encounter.id) { undoFor.current = encounter.id; undoStack.current = []; }
|
||||
const mutate = (fn: (e: Encounter) => Encounter) => {
|
||||
undoStack.current.push(encounter);
|
||||
if (undoStack.current.length > 50) undoStack.current.shift();
|
||||
// Transactional read-modify-write so rapid mutations can't clobber each other.
|
||||
void encountersRepo.mutate(encounter.id, fn);
|
||||
// The undo snapshot is captured INSIDE the transaction (fresh DB state, not the
|
||||
// possibly-stale render prop).
|
||||
void encountersRepo.mutate(encounter.id, (current) => {
|
||||
undoStack.current.push(current);
|
||||
if (undoStack.current.length > 50) undoStack.current.shift();
|
||||
return fn(current);
|
||||
});
|
||||
};
|
||||
const undo = () => {
|
||||
const prev = undoStack.current.pop();
|
||||
@@ -75,24 +84,19 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* After a concentrating PC takes damage, the app computes the Constitution
|
||||
* save DC and surfaces it — but never rolls. The player rolls their own save
|
||||
* and, on a failure, uses the Drop button on their sheet/panel. (Auto-rolling
|
||||
* would take the moment away from the player.)
|
||||
* After a concentrating combatant takes damage, compute the Constitution save
|
||||
* DC and surface it — but never roll. The player rolls their own save and, on
|
||||
* a failure, uses the Drop button on their sheet/panel. (Auto-rolling would
|
||||
* take the moment away from the player.) 5e only: PF2e has no such save —
|
||||
* sustained spells simply require the Sustain action, so there is nothing to roll.
|
||||
*/
|
||||
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
||||
if (damage <= 0) return;
|
||||
// 5e only: damage forces a Con save to hold concentration. PF2e has no such save —
|
||||
// sustained spells simply require the Sustain action, so there is nothing to roll.
|
||||
if (campaign.system !== '5e') return;
|
||||
const concentrationNote = (combatant: Combatant, damage: number): string | null => {
|
||||
if (damage <= 0 || campaign.system !== '5e') return null;
|
||||
// The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to
|
||||
// the linked Character's concentration (set when a seated player casts a spell).
|
||||
const ch = pcCharacter(combatant);
|
||||
const spellName = combatant.concentrating || ch?.concentration?.spellName;
|
||||
if (!spellName) return;
|
||||
const dc = concentrationDC(damage);
|
||||
void encountersRepo.mutate(encounter.id, (e) =>
|
||||
logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`));
|
||||
const spellName = combatant.concentrating || pcCharacter(combatant)?.concentration?.spellName;
|
||||
if (!spellName) return null;
|
||||
return `${combatant.name}: roll a DC ${concentrationDC(damage)} Constitution save or lose concentration on ${spellName}.`;
|
||||
};
|
||||
|
||||
/** Effective max HP for a combatant — drained / exhaustion 4 reduce it. */
|
||||
@@ -104,27 +108,36 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
conditions: c.conditions,
|
||||
}).max;
|
||||
|
||||
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
|
||||
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. The
|
||||
* upcoming combatant is derived from the FRESH state inside the transaction,
|
||||
* so rapid Next presses can't log stale or duplicate reminders. */
|
||||
const advanceTurn = () => {
|
||||
mutate((e) => nextTurn(e, campaign.system));
|
||||
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
||||
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
||||
const reminder = campaign.system === 'pf2e'
|
||||
? (() => {
|
||||
const dying = pcCharacter(upcoming)?.defenses.dying ?? 0;
|
||||
return dying > 0
|
||||
? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.`
|
||||
: `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`;
|
||||
})()
|
||||
: `${upcoming.name} is down — make a death saving throw.`;
|
||||
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, reminder));
|
||||
}
|
||||
mutate((e) => {
|
||||
const next = nextTurn(e, campaign.system);
|
||||
const upcoming = currentCombatant(next);
|
||||
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
||||
const reminder = campaign.system === 'pf2e'
|
||||
? (() => {
|
||||
const dying = pcCharacter(upcoming)?.defenses.dying ?? 0;
|
||||
return dying > 0
|
||||
? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.`
|
||||
: `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`;
|
||||
})()
|
||||
: `${upcoming.name} is down — make a death saving throw.`;
|
||||
return logEvent(next, reminder);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const rollAllInitiative = () => {
|
||||
const rolls: Record<string, number> = {};
|
||||
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
|
||||
mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants'));
|
||||
// GM-initiated "Roll all" button. Rolling happens over the FRESH combatant
|
||||
// list inside the transaction so anyone added since this render isn't skipped.
|
||||
mutate((e) => {
|
||||
const rolls: Record<string, number> = {};
|
||||
for (const c of e.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
|
||||
return logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants');
|
||||
});
|
||||
};
|
||||
|
||||
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
|
||||
@@ -132,8 +145,12 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
// reading — and the assistant's history — reflects the party as it was.
|
||||
// Rate against the PCs actually IN the fight (so adding a player updates the
|
||||
// difficulty); fall back to the campaign roster before any PC is added.
|
||||
const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
|
||||
const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||
const rosterPcLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||
const pcLevelsOf = (e: Encounter) => {
|
||||
const inFight = e.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
|
||||
return inFight.length > 0 ? inFight : rosterPcLevels;
|
||||
};
|
||||
const currentLevels = pcLevelsOf(encounter);
|
||||
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
|
||||
const monsters = encounter.combatants
|
||||
.filter((c) => c.kind === 'monster')
|
||||
@@ -209,10 +226,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={encounter.combatants.length === 0}
|
||||
onClick={() => mutate((e) => ({
|
||||
...startEncounter(e),
|
||||
...(currentLevels.length ? { partyLevelsSnapshot: currentLevels } : {}),
|
||||
}))}
|
||||
onClick={() => mutate((e) => {
|
||||
// Snapshot from the FRESH state, not the render prop's levels.
|
||||
const levels = pcLevelsOf(e);
|
||||
return { ...startEncounter(e), ...(levels.length ? { partyLevelsSnapshot: levels } : {}) };
|
||||
})}
|
||||
>
|
||||
Start combat
|
||||
</Button>
|
||||
@@ -258,44 +276,70 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
system={campaign.system}
|
||||
glossary={glossary}
|
||||
isCurrent={isActive && idx === encounter.turnIndex}
|
||||
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
||||
onDamage={(amt, type) => {
|
||||
const after = applyDamage(c, amt, type);
|
||||
const hpLoss = (c.hp.current - after.hp.current) + (c.hp.temp - after.hp.temp);
|
||||
const note = hpLoss === amt
|
||||
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
|
||||
// Surface (don't auto-apply) the death rules for downed PCs — the player
|
||||
// owns their character's death state, so we remind rather than mutate it.
|
||||
let reminder: string | null = null;
|
||||
if (c.kind !== 'monster' && hpLoss > 0) {
|
||||
if (campaign.system === 'pf2e') {
|
||||
if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`;
|
||||
else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`;
|
||||
} else {
|
||||
// Massive damage compares against the (possibly reduced) effective max.
|
||||
if (isMassiveDamageDeath(effMaxOf(c), after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`;
|
||||
else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`;
|
||||
}
|
||||
onChange={(patch) => mutate((e) => {
|
||||
// Functional patches read the FRESH combatant inside the transaction,
|
||||
// so derived edits (condition steps, toggles) can't work from a stale row.
|
||||
const cur = e.combatants.find((x) => x.id === c.id);
|
||||
if (!cur) return e;
|
||||
return updateCombatant(e, c.id, typeof patch === 'function' ? patch(cur) : patch);
|
||||
})}
|
||||
onAddCondition={(cond) => mutate((e) => {
|
||||
const cur = e.combatants.find((x) => x.id === c.id);
|
||||
if (!cur) return e;
|
||||
let next = updateCombatant(e, cur.id, { conditions: [...cur.conditions, cond] });
|
||||
// Immunity is a warning, not a wall — the GM may have a bypassing
|
||||
// effect in play, so apply it but flag the override in the log.
|
||||
if (isConditionImmune(cur.damageDefenses, cond.name)) {
|
||||
next = logEvent(next, `${cur.name} is immune to ${cond.name} per its stat block — applied anyway; remove if unintended.`);
|
||||
}
|
||||
return next;
|
||||
})}
|
||||
onDamage={(amt, type) => {
|
||||
// All damage math reads the FRESH combatant inside the transaction —
|
||||
// the render prop may be stale if another mutation landed since this draw.
|
||||
mutate((e) => {
|
||||
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
||||
const cur = e.combatants.find((x) => x.id === c.id);
|
||||
if (!cur) return e;
|
||||
const { after, dealt, overflow } = damageOutcome(cur, amt, type);
|
||||
const note = dealt === amt
|
||||
? `${cur.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||
: `${cur.name} takes ${dealt}${type ? ` ${type}` : ''} damage (${amt} before ${dealt < amt ? 'resistance' : 'vulnerability'})`;
|
||||
// Surface (don't auto-apply) the death rules for downed PCs — the player
|
||||
// owns their character's death state, so we remind rather than mutate it.
|
||||
let reminder: string | null = null;
|
||||
if (cur.kind !== 'monster' && dealt > 0) {
|
||||
if (campaign.system === 'pf2e') {
|
||||
if (cur.hp.current <= 0) reminder = `${cur.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`;
|
||||
else if (after.hp.current <= 0) reminder = `${cur.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`;
|
||||
} else {
|
||||
// Massive damage compares the OVERFLOW against the (possibly reduced) effective max.
|
||||
if (isMassiveDamageDeath(effMaxOf(cur), overflow)) reminder = `${cur.name} suffers massive damage and dies instantly — no death saves.`;
|
||||
else if (cur.hp.current <= 0) reminder = `${cur.name} took damage while down — mark a death save failure (two on a critical hit).`;
|
||||
}
|
||||
}
|
||||
let next = logEvent(updateCombatant(e, cur.id, { hp: after.hp }), note);
|
||||
if (reminder) next = logEvent(next, reminder);
|
||||
const conc = concentrationNote(cur, dealt);
|
||||
if (conc) next = logEvent(next, conc);
|
||||
return next;
|
||||
});
|
||||
noteConcentrationCheck(c, hpLoss);
|
||||
}}
|
||||
onHeal={(amt) => {
|
||||
// Cap at the effective max (drained / exhaustion 4), and for pf2e let
|
||||
// healing above 0 do the wake-up bookkeeping (Dying ends → Wounded +1).
|
||||
const healed = applyHealing(c, amt, effMaxOf(c));
|
||||
const woke = campaign.system === 'pf2e' && c.hp.current <= 0 && healed.hp.current > 0;
|
||||
const conditions = woke
|
||||
? c.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase()))
|
||||
: c.conditions;
|
||||
// Like damage, all of it derives from the FRESH combatant in-transaction.
|
||||
mutate((e) => {
|
||||
let next = logEvent(updateCombatant(e, c.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${amt}`);
|
||||
if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`);
|
||||
const cur = e.combatants.find((x) => x.id === c.id);
|
||||
if (!cur) return e;
|
||||
const healed = applyHealing(cur, amt, effMaxOf(cur));
|
||||
const rose = cur.hp.current <= 0 && healed.hp.current > 0;
|
||||
const woke = campaign.system === 'pf2e' && rose;
|
||||
const conditions = woke
|
||||
? cur.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase()))
|
||||
: cur.conditions;
|
||||
let next = logEvent(updateCombatant(e, cur.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${cur.name} heals ${amt}`);
|
||||
if (woke) next = logEvent(next, `${cur.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`);
|
||||
else if (rose && cur.kind !== 'monster') next = logEvent(next, `${cur.name} is back up — they wake; reset death saves on their sheet.`);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
@@ -340,16 +384,6 @@ function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; o
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -388,16 +422,26 @@ function AddCombatantBar({
|
||||
const ch = characters.find((c) => c.id === charId);
|
||||
if (!ch) return;
|
||||
const sys = getSystem(ch.system);
|
||||
// NO auto-roll: initiative starts unset (0) — the player rolls their own die
|
||||
// (or the GM uses the explicit "Roll all" button / types the result). Existing
|
||||
// sheet conditions and 5e exhaustion carry into the fight, and `level` comes
|
||||
// along for the difficulty budget + PF2e drained max-HP math.
|
||||
const conditions = [...ch.conditions];
|
||||
if (ch.system === '5e' && ch.defenses.exhaustion > 0
|
||||
&& !conditions.some((c) => c.name.trim().toLowerCase() === 'exhaustion')) {
|
||||
conditions.push({ name: 'Exhaustion', value: ch.defenses.exhaustion });
|
||||
}
|
||||
onAdd({
|
||||
id: newId(),
|
||||
name: ch.name,
|
||||
kind: ch.kind,
|
||||
characterId: ch.id,
|
||||
initiative: rollInitiativeFor(ch),
|
||||
level: ch.level,
|
||||
initiative: 0,
|
||||
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
|
||||
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus, ...(ch.equippedArmor ? { equippedArmor: ch.equippedArmor } : {}) }),
|
||||
hp: { ...ch.hp },
|
||||
conditions: [],
|
||||
conditions,
|
||||
notes: '',
|
||||
});
|
||||
};
|
||||
@@ -459,6 +503,7 @@ function CombatantRow({
|
||||
glossary,
|
||||
isCurrent,
|
||||
onChange,
|
||||
onAddCondition,
|
||||
onDamage,
|
||||
onHeal,
|
||||
onMove,
|
||||
@@ -468,7 +513,9 @@ function CombatantRow({
|
||||
system: SystemId;
|
||||
glossary: Map<string, string>;
|
||||
isCurrent: boolean;
|
||||
onChange: (patch: Partial<Combatant>) => void;
|
||||
/** Function patches receive the FRESH combatant inside the write transaction. */
|
||||
onChange: (patch: Partial<Combatant> | ((cur: Combatant) => Partial<Combatant>)) => void;
|
||||
onAddCondition: (cond: Condition) => void;
|
||||
onDamage: (amt: number, type?: string) => void;
|
||||
onHeal: (amt: number) => void;
|
||||
onMove: (dir: -1 | 1) => void;
|
||||
@@ -526,7 +573,7 @@ function CombatantRow({
|
||||
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
|
||||
onClick={() => onChange((cur) => ({ concentrating: cur.concentrating ? null : 'a spell' }))}
|
||||
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
|
||||
title={c.concentrating
|
||||
? `Concentrating on ${c.concentrating} — click to clear`
|
||||
@@ -570,23 +617,26 @@ function CombatantRow({
|
||||
<button
|
||||
aria-label={`Decrease ${cond.name}`}
|
||||
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
|
||||
onClick={() => onChange({
|
||||
conditions: (cond.value ?? 1) <= 1
|
||||
? c.conditions.filter((_, j) => j !== i)
|
||||
: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 1) - 1 } : x)),
|
||||
})}
|
||||
onClick={() => onChange((cur) => ({
|
||||
conditions: cur.conditions.flatMap((x, j) => {
|
||||
if (j !== i || x.name !== cond.name) return [x];
|
||||
return (x.value ?? 1) <= 1 ? [] : [{ ...x, value: (x.value ?? 1) - 1 }];
|
||||
}),
|
||||
}))}
|
||||
>−</button>
|
||||
<button
|
||||
aria-label={`Increase ${cond.name}`}
|
||||
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
|
||||
onClick={() => onChange({ conditions: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 0) + 1 } : x)) })}
|
||||
onClick={() => onChange((cur) => ({
|
||||
conditions: cur.conditions.map((x, j) => (j === i && x.name === cond.name ? { ...x, value: (x.value ?? 0) + 1 } : x)),
|
||||
}))}
|
||||
>+</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove ${cond.name}`}
|
||||
className="transition-opacity hover:opacity-60"
|
||||
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
||||
onClick={() => onChange((cur) => ({ conditions: cur.conditions.filter((x, j) => !(j === i && x.name === cond.name)) }))}
|
||||
>
|
||||
<X size={11} aria-hidden />
|
||||
</button>
|
||||
@@ -661,7 +711,8 @@ function CombatantRow({
|
||||
<ConditionPicker
|
||||
system={system}
|
||||
existing={c.conditions}
|
||||
onAdd={(cond) => onChange({ conditions: [...c.conditions, cond] })}
|
||||
defenses={c.damageDefenses}
|
||||
onAdd={onAddCondition}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -671,10 +722,13 @@ function CombatantRow({
|
||||
function ConditionPicker({
|
||||
system,
|
||||
existing,
|
||||
defenses,
|
||||
onAdd,
|
||||
}: {
|
||||
system: SystemId;
|
||||
existing: Condition[];
|
||||
/** snapshot used to tag options the creature is immune to (never disables them) */
|
||||
defenses?: CombatantDamageDefenses | undefined;
|
||||
onAdd: (cond: Condition) => void;
|
||||
}) {
|
||||
const [sel, setSel] = useState('');
|
||||
@@ -732,6 +786,7 @@ function ConditionPicker({
|
||||
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
|
||||
{cd.name}
|
||||
{cd.valued ? ' (#)' : ''}
|
||||
{isConditionImmune(defenses, cd.name) ? ' (immune)' : ''}
|
||||
{taken.has(cd.name) ? ' ✓' : ''}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isConditionImmune } from './conditionImmunity';
|
||||
import type { CombatantDamageDefenses } from '@/lib/schemas';
|
||||
|
||||
const def = (conditionImmune: string[]): CombatantDamageDefenses => ({
|
||||
resist: [], immune: [], vulnerable: [], conditionImmune, notes: [], resistFlat: [], weakness: [],
|
||||
});
|
||||
|
||||
describe('isConditionImmune', () => {
|
||||
it('matches 5e lowercase tokens against the picker-cased condition name', () => {
|
||||
const d = def(['poisoned', 'charmed']);
|
||||
expect(isConditionImmune(d, 'Poisoned')).toBe(true);
|
||||
expect(isConditionImmune(d, 'Frightened')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps effect-name variants to their condition (paralysis, fear, flat-footed)', () => {
|
||||
const d = def(['paralysis', 'fear', 'flat-footed']);
|
||||
expect(isConditionImmune(d, 'Paralyzed')).toBe(true);
|
||||
expect(isConditionImmune(d, 'Frightened')).toBe(true);
|
||||
expect(isConditionImmune(d, 'Off-Guard')).toBe(true); // Remaster rename
|
||||
});
|
||||
|
||||
it('ignores non-condition tokens and qualified immunities', () => {
|
||||
const d = def(['cold_iron', 'fatigued (from the effects of starvation or thirst)']);
|
||||
expect(isConditionImmune(d, 'Fatigued')).toBe(false);
|
||||
expect(isConditionImmune(d, 'Cold Iron')).toBe(true); // exact custom name still warns
|
||||
});
|
||||
|
||||
it('is false without a defenses snapshot or an empty list', () => {
|
||||
expect(isConditionImmune(undefined, 'Poisoned')).toBe(false);
|
||||
expect(isConditionImmune(def([]), 'Poisoned')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CombatantDamageDefenses } from '@/lib/schemas';
|
||||
|
||||
/**
|
||||
* Match a condition name against a combatant's snapshotted `conditionImmune`
|
||||
* list. Bestiary tokens are lowercase free text and may use underscores
|
||||
* ("cold_iron") or name the effect rather than the condition ("paralysis",
|
||||
* "fear", pre-Remaster "flat-footed"), so we normalize both sides and map the
|
||||
* known effect-name variants. Used to WARN the GM — never to block the apply.
|
||||
*/
|
||||
const IMMUNITY_ALIASES: Record<string, string> = {
|
||||
blind: 'blinded',
|
||||
blindness: 'blinded',
|
||||
charm: 'charmed',
|
||||
confusion: 'confused',
|
||||
fatigue: 'fatigued',
|
||||
fear: 'frightened',
|
||||
'fear effects': 'frightened',
|
||||
'flat footed': 'off guard', // Remaster rename
|
||||
grapple: 'grappled',
|
||||
paralysis: 'paralyzed',
|
||||
};
|
||||
|
||||
function norm(s: string): string {
|
||||
return s.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function isConditionImmune(
|
||||
defenses: CombatantDamageDefenses | undefined,
|
||||
conditionName: string,
|
||||
): boolean {
|
||||
const list = defenses?.conditionImmune;
|
||||
if (!list || list.length === 0) return false;
|
||||
const target = norm(conditionName);
|
||||
return list.some((raw) => {
|
||||
const token = norm(raw);
|
||||
return token === target || IMMUNITY_ALIASES[token] === target;
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import Fuse from 'fuse.js';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import { SYSTEM_OPTIONS } 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 { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
||||
import { type Character, type InventoryItem, type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||
@@ -29,9 +27,13 @@ type SortKey = 'name-asc' | 'name-desc' | 'num-asc' | 'num-desc';
|
||||
|
||||
export function CompendiumPage() {
|
||||
const activeCampaign = useActiveCampaign();
|
||||
const [system, setSystem] = useState<SystemId>(activeCampaign?.system ?? '5e');
|
||||
// Follow the active campaign's system (a live query that resolves async)
|
||||
// until the user explicitly toggles the switcher.
|
||||
const [systemOverride, setSystemOverride] = useState<SystemId | null>(null);
|
||||
const system: SystemId = systemOverride ?? activeCampaign?.system ?? '5e';
|
||||
const categories = categoriesForSystem(system);
|
||||
const [categoryId, setCategoryId] = useState(categories[0]!.id);
|
||||
// Falls back to the system's first category when the system changed under us.
|
||||
const category = categories.find((c) => c.id === categoryId) ?? categories[0]!;
|
||||
|
||||
const [data, setData] = useState<Entry[]>([]);
|
||||
@@ -51,6 +53,7 @@ export function CompendiumPage() {
|
||||
setQuery('');
|
||||
setFilters({});
|
||||
setSort('name-asc');
|
||||
setData([]); // don't leave the previous category's rows browsable while loading / on failure
|
||||
category.load().then(
|
||||
(d) => { if (!cancelled) { setData(d); setLoading(false); } },
|
||||
(e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load'); setLoading(false); } },
|
||||
@@ -103,7 +106,7 @@ export function CompendiumPage() {
|
||||
}, [query, fuse, filtered, sort, category]);
|
||||
|
||||
const changeSystem = (s: SystemId) => {
|
||||
setSystem(s);
|
||||
setSystemOverride(s);
|
||||
setCategoryId(categoriesForSystem(s)[0]!.id);
|
||||
};
|
||||
|
||||
@@ -188,7 +191,7 @@ export function CompendiumPage() {
|
||||
{category.filters.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{category.filters.map((f) => {
|
||||
const opts = f.options(data);
|
||||
const opts = f.options(allData); // include homebrew values as options
|
||||
if (opts.length === 0) return null;
|
||||
return (
|
||||
<Select
|
||||
@@ -296,7 +299,7 @@ function ActionBar({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses } }) {
|
||||
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; monsterRef?: string; damageDefenses?: CombatantDamageDefenses } }) {
|
||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
@@ -304,19 +307,22 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number;
|
||||
if (!activeEncounterId) return;
|
||||
const enc = await encountersRepo.get(activeEncounterId);
|
||||
if (!enc) { setMsg('Open an encounter in Combat first.'); return; }
|
||||
const initiative = rollDice('1d20', createRng()).total + stats.initBonus;
|
||||
await encountersRepo.save(
|
||||
addCombatant(enc, {
|
||||
// No auto-roll: the combatant joins at initiative 0 — rolling is the
|
||||
// tracker's explicit, user-clicked action. Transactional mutate so a
|
||||
// concurrent tracker edit isn't clobbered by a stale snapshot.
|
||||
await encountersRepo.mutate(activeEncounterId, (fresh) =>
|
||||
addCombatant(fresh, {
|
||||
id: newId(), name: stats.name, kind: 'monster',
|
||||
initiative, initBonus: stats.initBonus, ac: stats.ac,
|
||||
initiative: 0, initBonus: stats.initBonus, ac: stats.ac,
|
||||
hp: { current: stats.hp, max: stats.hp, temp: 0 },
|
||||
conditions: [], notes: '',
|
||||
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
|
||||
...(stats.level !== undefined ? { level: stats.level } : {}),
|
||||
...(stats.monsterRef ? { monsterRef: stats.monsterRef } : {}),
|
||||
...(stats.damageDefenses ? { damageDefenses: stats.damageDefenses } : {}),
|
||||
}),
|
||||
);
|
||||
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
|
||||
setMsg(`Added to "${enc.name}" — roll initiative in the tracker.`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,8 @@ function actionList(value: MonsterAction[] | string | undefined): MonsterAction[
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** A single "Label value" line in the Codex stat block. */
|
||||
function StatLine({ label, children }: { label: string; children?: React.ReactNode }) {
|
||||
/** A single "Label value" line in the Codex stat block (shared with the PF2e block). */
|
||||
export function StatLine({ label, children }: { label: string; children?: React.ReactNode }) {
|
||||
if (children === undefined || children === null || children === '' || children === false) return null;
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-ink-soft">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CompendiumEntry } from '@/lib/compendium/types';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { StatLine } from './MonsterDetail';
|
||||
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
||||
const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String).filter(Boolean) : []);
|
||||
const numDict = (v: unknown): [string, number][] =>
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? (Object.entries(v as Record<string, unknown>).filter(([, n]) => typeof n === 'number') as [string, number][])
|
||||
: [];
|
||||
|
||||
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
|
||||
/** { land: 25, fly: 150, max: 150 } → "25 feet, fly 150 feet" */
|
||||
function speedLine(v: unknown): string {
|
||||
const entries = numDict(v).filter(([k]) => k !== 'max');
|
||||
const land = entries.find(([k]) => k === 'land');
|
||||
const rest = entries.filter(([k]) => k !== 'land');
|
||||
return [...(land ? [`${land[1]} feet`] : []), ...rest.map(([k, n]) => `${k} ${n} feet`)].join(', ');
|
||||
}
|
||||
|
||||
/** { cold: 15 } → "cold 15" */
|
||||
function typedValues(v: unknown): string {
|
||||
return numDict(v).map(([k, n]) => `${k} ${n}`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured PF2e statblock from the AoN creature fields — the parity partner
|
||||
* of the 5e MonsterDetail. AoN ability values are modifiers, not scores.
|
||||
*/
|
||||
export function Pf2eCreatureDetail({ entry: c }: { entry: CompendiumEntry }) {
|
||||
const abilities: [string, number | undefined][] = [
|
||||
['STR', num(c.strength)],
|
||||
['DEX', num(c.dexterity)],
|
||||
['CON', num(c.constitution)],
|
||||
['INT', num(c.intelligence)],
|
||||
['WIS', num(c.wisdom)],
|
||||
['CHA', num(c.charisma)],
|
||||
];
|
||||
const level = num(c.level);
|
||||
const perception = num(c.perception);
|
||||
const sense = str(c.sense);
|
||||
const saves = ([['Fort', num(c.fortitude_save)], ['Ref', num(c.reflex_save)], ['Will', num(c.will_save)]] as const)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => `${k} ${formatModifier(v!)}`)
|
||||
.join(', ');
|
||||
const skills = numDict(c.skill_mod).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${cap(k)} ${formatModifier(v)}`).join(', ')
|
||||
|| list(c.skill).join(', ');
|
||||
const size = list(c.size);
|
||||
const traits = [...new Set([...size, ...list(c.trait)])];
|
||||
const rarity = str(c.rarity);
|
||||
const abilityNames = list(c.creature_ability);
|
||||
const text = str(c.text) || str(c.summary);
|
||||
const ac = num(c.ac);
|
||||
const acLine = ac !== undefined ? `${ac}${saves ? `; ${saves}` : ''}` : saves;
|
||||
|
||||
return (
|
||||
<div className="font-display">
|
||||
<header>
|
||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-danger">{c.name}</h2>
|
||||
<p className="text-sm italic text-muted">
|
||||
{level !== undefined ? `Creature ${level}` : 'Creature'}
|
||||
{rarity && rarity !== 'common' ? ` · ${cap(rarity)}` : ''}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{traits.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{traits.map((t) => <Badge key={t}>{t}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="gilt-rule my-4" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<StatLine label="Perception">
|
||||
{perception !== undefined ? formatModifier(perception) + (sense ? `; ${sense}` : '') : sense || null}
|
||||
</StatLine>
|
||||
<StatLine label="Languages">{list(c.language).join(', ') || null}</StatLine>
|
||||
<StatLine label="Skills">{skills || null}</StatLine>
|
||||
</div>
|
||||
|
||||
<hr className="gilt-rule my-4" />
|
||||
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{abilities.map(([label, mod]) => (
|
||||
<div key={label} className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-surface-2 px-1 py-2 text-center">
|
||||
<span className="smallcaps text-danger" style={{ fontSize: 9 }}>{label}</span>
|
||||
<span className="font-display text-lg font-semibold text-ink">{mod !== undefined ? formatModifier(mod) : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className="gilt-rule my-4" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<StatLine label="AC">{acLine || null}</StatLine>
|
||||
<StatLine label="HP">{num(c.hp) ?? null}</StatLine>
|
||||
<StatLine label="Immunities">{list(c.immunity).join(', ') || null}</StatLine>
|
||||
<StatLine label="Resistances">{typedValues(c.resistance) || null}</StatLine>
|
||||
<StatLine label="Weaknesses">{typedValues(c.weakness) || null}</StatLine>
|
||||
<StatLine label="Speed">{speedLine(c.speed) || null}</StatLine>
|
||||
<StatLine label="Abilities">{abilityNames.join(', ') || null}</StatLine>
|
||||
</div>
|
||||
|
||||
{text && (
|
||||
<>
|
||||
<hr className="gilt-rule my-4" />
|
||||
<p className="whitespace-pre-line text-[15px] leading-relaxed text-ink-soft">{text}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,13 @@ import type {
|
||||
CompendiumEntry,
|
||||
} from '@/lib/compendium/types';
|
||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||
import { formatPf2ePrice } from '@/lib/compendium';
|
||||
import { HOMEBREW_FIELDS } from '@/features/world/homebrew';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
|
||||
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
/** Truthy "yes"/"true"/boolean flag from the messy SRD fields. */
|
||||
const flag = (v: unknown): boolean => v === true || (typeof v === 'string' && /^(yes|true)$/i.test(v.trim()));
|
||||
|
||||
/** Editorial header shared by every detail renderer: Spectral title + italic sub + gilt rule. */
|
||||
function Header({
|
||||
@@ -87,20 +91,29 @@ function Traits({ traits }: { traits?: string[] | undefined }) {
|
||||
}
|
||||
|
||||
export function Spell5eDetail({ entry: s }: { entry: Spell }) {
|
||||
const concentration = flag(s.requires_concentration) || flag(s.concentration);
|
||||
const ritual = flag(s.can_be_cast_as_ritual) || flag(s.ritual);
|
||||
// Statblock style: concentration lives on the duration ("Concentration, up to 1 minute").
|
||||
const duration = concentration && s.duration
|
||||
? `Concentration, ${s.duration.charAt(0).toLowerCase()}${s.duration.slice(1)}`
|
||||
: s.duration;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header
|
||||
title={s.name}
|
||||
subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
|
||||
subtitle={(s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`) + (ritual ? ' (ritual)' : '')}
|
||||
tone="text-info"
|
||||
/>
|
||||
<hr className="gilt-rule my-1" />
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<FactWell label="Casting Time" value={s.casting_time} />
|
||||
<FactWell label="Range" value={s.range} />
|
||||
<FactWell label="Duration" value={s.duration} />
|
||||
<FactWell label="Duration" value={duration} />
|
||||
<FactWell label="Components" value={s.components} />
|
||||
</div>
|
||||
{s.material && (
|
||||
<p className="text-sm text-muted"><strong className="italic text-ink">Materials.</strong> {s.material}</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{s.desc}</p>
|
||||
{s.higher_level && (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted">
|
||||
@@ -112,14 +125,14 @@ export function Spell5eDetail({ entry: s }: { entry: Spell }) {
|
||||
}
|
||||
|
||||
export function Item5eDetail({ entry: item }: { entry: MagicItem }) {
|
||||
// The data's value already starts with the words "requires attunement".
|
||||
const att = item.requires_attunement?.trim();
|
||||
const attunement = att ? (/^requires attunement/i.test(att) ? att : `requires attunement ${att}`) : '';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header
|
||||
title={item.name}
|
||||
subtitle={
|
||||
[item.type, item.rarity].filter(Boolean).join(', ') +
|
||||
(item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '')
|
||||
}
|
||||
subtitle={[item.type, item.rarity].filter(Boolean).join(', ') + (attunement ? ` (${attunement})` : '')}
|
||||
/>
|
||||
<hr className="gilt-rule my-1" />
|
||||
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{item.desc}</p>
|
||||
@@ -164,6 +177,9 @@ export function Feat5eDetail({ entry: f }: { entry: Feat5e }) {
|
||||
<div className="space-y-3">
|
||||
<Header title={f.name} subtitle="Feat" />
|
||||
<hr className="gilt-rule my-1" />
|
||||
{f.prerequisite && (
|
||||
<p className="text-sm italic text-muted"><strong className="text-ink">Prerequisite:</strong> {f.prerequisite}</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{f.description}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -186,9 +202,13 @@ export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
|
||||
|
||||
/** Renderer for homebrew entries: header, field facts, description. */
|
||||
export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
|
||||
const skip = new Set(['name', 'slug', 'description', '__homebrew', '__kind']);
|
||||
const facts = Object.entries(entry).filter(([k, v]) => !skip.has(k) && v !== undefined && v !== '');
|
||||
const kind = typeof entry.__kind === 'string' ? entry.__kind : 'homebrew';
|
||||
// Render only the authored fields — entries also carry lowercase dataset
|
||||
// aliases (level_int, cr, …) that would duplicate every fact.
|
||||
const defs = (HOMEBREW_FIELDS as Record<string, { key: string; label: string }[]>)[kind] ?? [];
|
||||
const facts = defs
|
||||
.map((f) => [f.label, entry[f.key]] as const)
|
||||
.filter(([, v]) => v !== undefined && v !== '');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title={entry.name} subtitle={`Homebrew ${kind}`} />
|
||||
@@ -205,6 +225,75 @@ export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface Race5e {
|
||||
name: string;
|
||||
desc?: string;
|
||||
asi?: string;
|
||||
speed?: string;
|
||||
vision?: string;
|
||||
traits?: string;
|
||||
}
|
||||
|
||||
/** "***Ability Score Increase.*** Your Dexterity…" → bold lead-in + body. */
|
||||
function TraitParagraph({ text }: { text?: string | undefined }) {
|
||||
if (!text) return null;
|
||||
const m = /^\*{2,3}([^*]+)\*{2,3}\s*(.*)$/s.exec(text.trim());
|
||||
const [label, body] = m ? [m[1]!.trim(), m[2]!] : [null, text];
|
||||
return (
|
||||
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">
|
||||
{label && <strong className="italic text-ink">{label} </strong>}
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Race5eDetail({ entry: r }: { entry: Race5e }) {
|
||||
// desc is usually just the boilerplate "<Name> Traits" heading — skip it then.
|
||||
const desc = r.desc && r.desc.trim() !== `${r.name} Traits` ? r.desc : '';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title={r.name} subtitle="Race" />
|
||||
<hr className="gilt-rule my-1" />
|
||||
{desc && <p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{desc}</p>}
|
||||
<TraitParagraph text={r.asi} />
|
||||
<TraitParagraph text={r.speed} />
|
||||
<TraitParagraph text={r.vision} />
|
||||
<TraitParagraph text={r.traits} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Background5e {
|
||||
name: string;
|
||||
desc?: string;
|
||||
skills?: string;
|
||||
tools?: string;
|
||||
languages?: string;
|
||||
feature?: string;
|
||||
}
|
||||
|
||||
export function Background5eDetail({ entry: b }: { entry: Background5e }) {
|
||||
const facts: [string, string | undefined][] = [
|
||||
['Skills', b.skills],
|
||||
['Tools', b.tools],
|
||||
['Languages', b.languages],
|
||||
['Feature', b.feature],
|
||||
];
|
||||
const shown = facts.filter(([, v]) => v && v.trim() !== '');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title={b.name} subtitle="Background" />
|
||||
<hr className="gilt-rule my-1" />
|
||||
{shown.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{shown.map(([k, v]) => <FactWell key={k} label={k} value={v} />)}
|
||||
</div>
|
||||
)}
|
||||
{b.desc && <p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{b.desc}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic renderer for PF2e (AoN) entries: header, traits, key facts, text body. */
|
||||
export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
||||
const traits = (entry.trait as string[] | undefined) ?? undefined;
|
||||
@@ -213,7 +302,7 @@ export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
||||
['Rarity', entry.rarity],
|
||||
['Tradition', entry.tradition],
|
||||
['Actions', entry.actions],
|
||||
['Price', entry.price],
|
||||
['Price', formatPf2ePrice(entry.price)],
|
||||
['AC', entry.ac],
|
||||
['HP', entry.hp],
|
||||
['Damage', entry.damage],
|
||||
|
||||
@@ -36,10 +36,22 @@ describe('category registry', () => {
|
||||
expect(fivee).toContain('Bestiary');
|
||||
expect(fivee).toContain('Spells');
|
||||
expect(fivee).toContain('Feats');
|
||||
// parity with pf2e Ancestries/Backgrounds
|
||||
expect(fivee).toContain('Races');
|
||||
expect(fivee).toContain('Backgrounds');
|
||||
expect(pf2e).toContain('Bestiary');
|
||||
expect(pf2e).toContain('Spells');
|
||||
expect(pf2e.length).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it('5e mundane gear can be added to a character like pf2e equipment', () => {
|
||||
const linkable = (sys: '5e' | 'pf2e', label: string) =>
|
||||
categoriesForSystem(sys).find((c) => c.label === label)?.linkAs;
|
||||
expect(linkable('5e', 'Weapons')).toBe('item');
|
||||
expect(linkable('5e', 'Armor')).toBe('item');
|
||||
expect(linkable('pf2e', 'Weapons')).toBe('item');
|
||||
expect(linkable('pf2e', 'Armor')).toBe('item');
|
||||
});
|
||||
it('every category has a unique id', () => {
|
||||
const all = [...categoriesForSystem('5e'), ...categoriesForSystem('pf2e')];
|
||||
const ids = all.map((c) => c.id);
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
loadFeats5e,
|
||||
loadConditions5e,
|
||||
loadClasses,
|
||||
loadRaces5e,
|
||||
loadBackgrounds5e,
|
||||
loadPf2e,
|
||||
crLabel,
|
||||
} from '@/lib/compendium';
|
||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||
import { MonsterDetail } from './MonsterDetail';
|
||||
import { Pf2eCreatureDetail } from './Pf2eCreatureDetail';
|
||||
import {
|
||||
Spell5eDetail,
|
||||
Item5eDetail,
|
||||
@@ -25,6 +28,8 @@ import {
|
||||
Armor5eDetail,
|
||||
Feat5eDetail,
|
||||
Condition5eDetail,
|
||||
Race5eDetail,
|
||||
Background5eDetail,
|
||||
Pf2eDetail,
|
||||
ClassDetail,
|
||||
} from './details';
|
||||
@@ -51,7 +56,7 @@ export interface CategoryDef {
|
||||
/** enables "add to character" for spells/items */
|
||||
linkAs?: 'spell' | 'item';
|
||||
/** enables "add to combat" for monsters/creatures */
|
||||
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses };
|
||||
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; monsterRef?: string; damageDefenses?: CombatantDamageDefenses };
|
||||
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
|
||||
numericSort?: { label: string; get: (e: Entry) => number };
|
||||
}
|
||||
@@ -171,6 +176,8 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
|
||||
initBonus: abilityModifier(m.dexterity ?? 10),
|
||||
...(m.cr !== undefined ? { cr: m.cr } : {}),
|
||||
// route back to the statblock mid-fight (legendary actions, senses, …)
|
||||
...(m.slug ? { monsterRef: m.slug } : {}),
|
||||
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
|
||||
};
|
||||
},
|
||||
@@ -182,6 +189,22 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
],
|
||||
},
|
||||
classCategory('5e'),
|
||||
{
|
||||
id: '5e-races', system: '5e', label: 'Races',
|
||||
load: () => loadRaces5e() as unknown as Promise<Entry[]>,
|
||||
searchKeys: ['name', 'traits'],
|
||||
meta: () => '',
|
||||
detail: (e) => <Race5eDetail entry={e as never} />,
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
id: '5e-backgrounds', system: '5e', label: 'Backgrounds',
|
||||
load: () => loadBackgrounds5e() as unknown as Promise<Entry[]>,
|
||||
searchKeys: ['name', 'skills', 'feature'],
|
||||
meta: (e) => (e.skills as string) ?? '',
|
||||
detail: (e) => <Background5eDetail entry={e as never} />,
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
|
||||
load: () => loadSpells() as unknown as Promise<Entry[]>,
|
||||
@@ -207,7 +230,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5e-weapons', system: '5e', label: 'Weapons',
|
||||
id: '5e-weapons', system: '5e', label: 'Weapons', linkAs: 'item',
|
||||
load: () => loadWeapons5e() as unknown as Promise<Entry[]>,
|
||||
searchKeys: ['name'],
|
||||
meta: (e) => (e.category as string) ?? '',
|
||||
@@ -217,7 +240,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5e-armor', system: '5e', label: 'Armor',
|
||||
id: '5e-armor', system: '5e', label: 'Armor', linkAs: 'item',
|
||||
load: () => loadArmor5e() as unknown as Promise<Entry[]>,
|
||||
searchKeys: ['name'],
|
||||
meta: (e) => (e.type as string) ?? '',
|
||||
@@ -246,6 +269,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
// ---------- Pathfinder 2e ----------
|
||||
{
|
||||
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
|
||||
detail: (e) => <Pf2eCreatureDetail entry={e} />,
|
||||
toCombatant: (e) => {
|
||||
const d = normalizeMonsterDefensesPf2e(e as { immunity?: unknown; resistance?: unknown; weakness?: unknown });
|
||||
const hasDef = d.immune.length || d.conditionImmune.length || d.resistFlat.length || d.weakness.length;
|
||||
@@ -255,6 +279,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
hp: Number(e.hp) || 1,
|
||||
initBonus: Number(e.perception) || 0,
|
||||
...(e.level !== undefined ? { level: Number(e.level) } : {}),
|
||||
...(typeof e.slug === 'string' ? { monsterRef: e.slug } : {}),
|
||||
...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { Check, Lock, RadioTower, X } from 'lucide-react';
|
||||
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
||||
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
||||
import { hostSession, stopSession, grantSeat, denySeat } from '@/lib/sync/wsSync';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { cloudUsername } from '@/lib/cloud/client';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
@@ -111,7 +111,7 @@ function SeatRequestRow({ req }: { req: SeatRequest }) {
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button>
|
||||
{req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeSeatRequest(req.playerId)}>Deny</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => denySeat(req.playerId)}>Deny</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { buildSnapshot } from '@/lib/sync/snapshot';
|
||||
import type { Snapshot } from '@/lib/sync/messages';
|
||||
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
||||
import { pushSnapshot, pushImage, updateSeatCharacter } from '@/lib/sync/wsSync';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useMaps, useCalendar, useQuests } from '@/features/world/hooks';
|
||||
@@ -18,6 +18,7 @@ import { useMaps, useCalendar, useQuests } from '@/features/world/hooks';
|
||||
export function useSessionBroadcaster(campaign: Campaign | null): void {
|
||||
const role = useSessionStore((s) => s.role);
|
||||
const status = useSessionStore((s) => s.status);
|
||||
const roster = useSessionStore((s) => s.roster);
|
||||
const cid = campaign?.id ?? '';
|
||||
const characters = useCharacters(cid);
|
||||
const encounters = useEncounters(cid);
|
||||
@@ -29,11 +30,24 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
|
||||
const activeHandout = useUiStore((s) => s.activeHandout);
|
||||
|
||||
const debouncedPush = useDebouncedCallback((s: Snapshot) => pushSnapshot(s), 250);
|
||||
// last character.updatedAt pushed to each seat, so GM-side edits are forwarded
|
||||
// exactly once per change (and a re-granted sheet doesn't ping-pong).
|
||||
const seatSent = useRef(new Map<string, string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'gm' || status !== 'connected' || !campaign) return;
|
||||
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout, quests });
|
||||
debouncedPush(snapshot);
|
||||
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
|
||||
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, quests, debouncedPush]);
|
||||
// Keep seated players' live sheets in step with GM-side edits (tracker damage,
|
||||
// rests, compendium adds) — without this, only grant-time state ever reached them.
|
||||
for (const entry of roster) {
|
||||
if (!entry.characterId) continue;
|
||||
const ch = characters.find((c) => c.id === entry.characterId);
|
||||
if (!ch) continue;
|
||||
if (seatSent.current.get(entry.playerId) === ch.updatedAt) continue;
|
||||
seatSent.current.set(entry.playerId, ch.updatedAt);
|
||||
updateSeatCharacter(entry.playerId, ch);
|
||||
}
|
||||
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, quests, roster, debouncedPush]);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionGuide } from './ActionGuide';
|
||||
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { sendPlayerRoll } from '@/lib/sync/wsSync';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -89,8 +90,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spell slots */}
|
||||
{slots.length > 0 && (
|
||||
{/* Spell slots (incl. warlock pact magic — consumed by casting, so it must be visible) */}
|
||||
{(slots.length > 0 || (c.spellcasting.pact?.max ?? 0) > 0) && (
|
||||
<div className="rounded-lg border border-line bg-panel p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-ink">Spell slots</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -107,6 +108,19 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
<span className="ml-auto text-xs tabular-nums text-muted">{s.current}/{s.max}</span>
|
||||
</div>
|
||||
))}
|
||||
{c.spellcasting.pact && c.spellcasting.pact.max > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 text-xs text-muted" title={`Pact magic — level ${c.spellcasting.pact.level}, refreshes on a short rest`}>Pact L{c.spellcasting.pact.level}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: c.spellcasting.pact.max }, (_, i) => (
|
||||
<button key={i} aria-label={`Pact slot ${i + 1}`}
|
||||
onClick={() => onPatch({ spellcasting: { ...c.spellcasting, pact: { ...c.spellcasting.pact!, current: i < c.spellcasting.pact!.current ? i : i + 1 } } })}
|
||||
className={cn('h-5 w-5 rounded-full border', i < c.spellcasting.pact!.current ? 'border-violet-400 bg-violet-400' : 'border-line bg-surface')} />
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto text-xs tabular-nums text-muted">{c.spellcasting.pact.current}/{c.spellcasting.pact.max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,12 +249,24 @@ const QUICK_DICE = ['1d20', '1d4', '1d6', '1d8', '1d10', '1d12', '1d100'];
|
||||
|
||||
function DiceBox({ characterId }: { characterId: string }) {
|
||||
const [expr, setExpr] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const roll = (expression: string, label: string) => {
|
||||
const mode = useRollStore.getState().mode;
|
||||
const applied = applyRollMode(expression, mode);
|
||||
const result = rollDice(applied, createRng());
|
||||
// The expression is player-typed — a typo must show a message, not throw.
|
||||
let result;
|
||||
try {
|
||||
result = rollDice(applied, createRng());
|
||||
} catch {
|
||||
setError(`Can't roll “${expression}” — try something like 2d6+3.`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const tag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : '';
|
||||
useRollStore.getState().push({ label: label + tag, result });
|
||||
// Show the player's own roll in their local table feed too (the server only
|
||||
// relays it to OTHERS), then broadcast it.
|
||||
usePlayerSessionStore.getState().addRoll({ playerName: 'You', label: label + tag, expression: applied, total: result.total, breakdown: result.breakdown });
|
||||
sendPlayerRoll(characterId, label + tag, applied, result.total, result.breakdown);
|
||||
};
|
||||
return (
|
||||
@@ -253,6 +279,7 @@ function DiceBox({ characterId }: { characterId: string }) {
|
||||
<Input className="h-8" value={expr} onChange={(e) => setExpr(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && expr.trim()) { roll(expr.trim(), expr.trim()); setExpr(''); } }} placeholder="e.g. 2d6+3" aria-label="Dice expression" />
|
||||
<Button size="sm" variant="primary" disabled={!expr.trim()} onClick={() => { roll(expr.trim(), expr.trim()); setExpr(''); }}>Roll</Button>
|
||||
</div>
|
||||
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,17 @@ export function PlayerViewPage() {
|
||||
const joinIntent = useSessionStore((s) => s.joinIntent);
|
||||
const room = roomParam();
|
||||
useEffect(() => {
|
||||
if (room && room !== joinIntent?.joinCode) useSessionStore.getState().setJoinIntent({ joinCode: room });
|
||||
}, [room, joinIntent?.joinCode]);
|
||||
if (!room) return;
|
||||
if (room !== useSessionStore.getState().joinIntent?.joinCode) {
|
||||
useSessionStore.getState().setJoinIntent({ joinCode: room });
|
||||
}
|
||||
// Consume the param: once the intent is recorded, drop ?room from the URL so an
|
||||
// explicit "Leave session" (which nulls the intent) can't instantly re-join —
|
||||
// previously the effect saw room !== undefined again and reconnected in a second.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('room');
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
}, [room]);
|
||||
|
||||
if (joinIntent || room) return <NetworkedPlayerView />;
|
||||
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
|
||||
@@ -91,6 +100,22 @@ function NetworkedPlayerView() {
|
||||
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
||||
const [needPw, setNeedPw] = useState(false);
|
||||
const [pwInput, setPwInput] = useState('');
|
||||
const [claimStale, setClaimStale] = useState(false);
|
||||
|
||||
// An explicit GM deny arrives as 'seatDenied', but a claim sent while the socket
|
||||
// is momentarily closed is silently dropped — the player would wait on "pending"
|
||||
// forever with every claim button disabled. Time the request out back to the
|
||||
// picker as a backstop so they can try again.
|
||||
useEffect(() => {
|
||||
if (seatStatus !== 'pending') return;
|
||||
const t = setTimeout(() => {
|
||||
if (usePlayerSessionStore.getState().seatStatus === 'pending') {
|
||||
usePlayerSessionStore.getState().setSeatStatus('none');
|
||||
setClaimStale(true);
|
||||
}
|
||||
}, 60_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [seatStatus]);
|
||||
|
||||
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
||||
// the player's own PCs, so they can push one the GM hasn't added yet.
|
||||
@@ -125,7 +150,10 @@ function NetworkedPlayerView() {
|
||||
</Modal>
|
||||
) : null;
|
||||
|
||||
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
||||
const handleClaim = (characterId: string) => {
|
||||
setClaimStale(false);
|
||||
claimSeat(characterId, localChars.get(characterId));
|
||||
};
|
||||
const handlePatch = (diff: Partial<Character>) => {
|
||||
const cur = usePlayerSessionStore.getState().myCharacter;
|
||||
if (!cur) return;
|
||||
@@ -147,7 +175,18 @@ function NetworkedPlayerView() {
|
||||
{myCharacter && seatStatus === 'granted' ? (
|
||||
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
||||
) : (
|
||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||
<>
|
||||
{seatStatus === 'denied' ? (
|
||||
<p className="mb-2 text-sm text-warning" role="status">
|
||||
The GM declined your seat request. Pick a character to ask again, or check with your GM.
|
||||
</p>
|
||||
) : claimStale && seatStatus !== 'pending' && (
|
||||
<p className="mb-2 text-sm text-warning" role="status">
|
||||
Your seat request wasn't approved — the GM may have denied or missed it. Pick a character to ask again.
|
||||
</p>
|
||||
)}
|
||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||
</>
|
||||
)}
|
||||
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
||||
<RollFeed />
|
||||
|
||||
@@ -2,15 +2,25 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
||||
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
|
||||
import { CloudError } from '@/lib/cloud/client';
|
||||
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, deleteCloudCampaign, getUsage, req, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
|
||||
import { characterSchema } from '@/lib/schemas';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { useCampaigns } from '@/features/campaigns/hooks';
|
||||
import { useCloudUser } from '@/features/cloud/cloudAuth';
|
||||
import { useCloudLinkStore } from '@/stores/cloudLinkStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
|
||||
/** Owner-only on the server side: delete one of your published characters. */
|
||||
function unpublishCharacter(characterId: string): Promise<{ ok: boolean }> {
|
||||
return req<{ ok: boolean }>(`/characters/${characterId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** Settings: publish a campaign for players, join one, and publish your character (it stays yours + syncs). */
|
||||
export function CloudCampaigns() {
|
||||
const signedIn = !!cloudUsername();
|
||||
const signedIn = !!useCloudUser();
|
||||
const [list, setList] = useState<CloudCampaignInfo[]>([]);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -21,8 +31,12 @@ export function CloudCampaigns() {
|
||||
const [charId, setCharId] = useState('');
|
||||
const [targetCloud, setTargetCloud] = useState('');
|
||||
const [viewing, setViewing] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
||||
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
|
||||
const campaignLinks = useCloudLinkStore((s) => s.campaignLinks);
|
||||
const characterLinks = useCloudLinkStore((s) => s.characterLinks);
|
||||
const { linkCampaign, linkCharacter, unlinkCharacter } = useCloudLinkStore.getState();
|
||||
|
||||
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
||||
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
|
||||
@@ -33,6 +47,22 @@ export function CloudCampaigns() {
|
||||
try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
/** GM: copy a player's published sheet into the local db under a fresh id. */
|
||||
const importCloudCharacter = async (p: CloudCharInfo, cloudCampaignId: string): Promise<string> => {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(p.data); } catch { throw new CloudError(`Couldn't read ${p.name}'s sheet data.`); }
|
||||
const ts = new Date().toISOString();
|
||||
// Land it in the local campaign this cloud campaign was published from, if known.
|
||||
const localCampaignId = localCampaigns.find((lc) => campaignLinks[lc.id] === cloudCampaignId)?.id ?? '';
|
||||
const parsed = characterSchema.safeParse({
|
||||
...(raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}),
|
||||
id: newId(), campaignId: localCampaignId, createdAt: ts, updatedAt: ts,
|
||||
});
|
||||
if (!parsed.success) throw new CloudError(`${p.name}'s sheet couldn't be read — ask them to re-publish it.`);
|
||||
await charactersRepo.insert(parsed.data);
|
||||
return parsed.data.name;
|
||||
};
|
||||
|
||||
if (!signedIn) {
|
||||
return (
|
||||
<section className="rounded-lg border border-line bg-panel p-4">
|
||||
@@ -43,6 +73,9 @@ export function CloudCampaigns() {
|
||||
}
|
||||
|
||||
const owned = list.filter((c) => c.role === 'owner');
|
||||
// Re-clicking Publish on an already-published campaign would mint a duplicate
|
||||
// cloud campaign (each with its own invite code) — block it via the stored link.
|
||||
const pubAlreadyPublished = !!pubCampaign && list.some((c) => c.id === campaignLinks[pubCampaign]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
||||
@@ -62,6 +95,17 @@ export function CloudCampaigns() {
|
||||
<Button size="sm" variant="ghost" title="Invalidate the old code and mint a new one" onClick={run(async () => { const r = await rotateInvite(c.id); setMsg(`New invite code: ${r.inviteCode}`); refresh(); })}>Rotate invite</Button>
|
||||
)}
|
||||
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
|
||||
{c.role === 'owner' && (
|
||||
confirmDelete === c.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-xs text-danger">Delete “{c.name}” from the cloud (published characters too)?</span>
|
||||
<Button size="sm" variant="danger" onClick={run(async () => { await deleteCloudCampaign(c.id); setConfirmDelete(null); setMsg(`Deleted “${c.name}” from the cloud.`); refresh(); })}>Delete</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>Keep</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" className="text-danger" onClick={() => setConfirmDelete(c.id)}>Delete…</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{viewing === c.id && (
|
||||
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
|
||||
@@ -70,6 +114,14 @@ export function CloudCampaigns() {
|
||||
<span className="text-ink">{p.name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted">{p.mine ? 'yours' : 'a player’s'}</span>
|
||||
{!p.mine && p.data && (
|
||||
<button className="text-accent hover:underline" title="Save a copy of this player's sheet into your local roster"
|
||||
onClick={run(async () => { const name = await importCloudCharacter(p, c.id); setMsg(`Imported ${name} — it's in your Characters list.`); })}>import</button>
|
||||
)}
|
||||
{p.mine && (
|
||||
<button className="text-danger hover:underline" title="Remove this character from the shared campaign (your local copy is untouched)"
|
||||
onClick={run(async () => { await unpublishCharacter(p.id); unlinkCharacter(p.id); setMsg(`Unpublished ${p.name}.`); setParty(await listCloudCharacters(c.id)); })}>unpublish</button>
|
||||
)}
|
||||
{!p.mine && (
|
||||
<button className="text-danger hover:underline" title="Remove this player and their characters from the campaign"
|
||||
onClick={run(async () => { await removeCloudMember(c.id, p.ownerUserId); setMsg('Player removed.'); setParty(await listCloudCharacters(c.id)); })}>remove</button>
|
||||
@@ -92,8 +144,9 @@ export function CloudCampaigns() {
|
||||
<option value="">Choose a campaign…</option>
|
||||
{localCampaigns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
<Button size="sm" variant="primary" disabled={busy || !pubCampaign} onClick={run(async () => { const c = localCampaigns.find((x) => x.id === pubCampaign); if (!c) return; await createCloudCampaign(c.name, c.system); setMsg(`Published “${c.name}” — share its invite code.`); refresh(); })}>Publish</Button>
|
||||
<Button size="sm" variant="primary" disabled={busy || !pubCampaign || pubAlreadyPublished} onClick={run(async () => { const c = localCampaigns.find((x) => x.id === pubCampaign); if (!c) return; const created = await createCloudCampaign(c.name, c.system); linkCampaign(c.id, created.id); setMsg(`Published “${c.name}” — share its invite code.`); refresh(); })}>Publish</Button>
|
||||
</div>
|
||||
{pubAlreadyPublished && <p className="mt-1 text-xs text-muted">Already published — share its invite code from the list above.</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -116,7 +169,11 @@ export function CloudCampaigns() {
|
||||
<option value="">…to which campaign</option>
|
||||
{list.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
<Button size="sm" variant="primary" disabled={busy || !charId || !targetCloud} onClick={run(async () => { const ch = (localChars ?? []).find((c) => c.id === charId); if (!ch) return; await publishCharacter(targetCloud, { id: ch.id, name: ch.name, data: JSON.stringify(ch) }); setMsg(`Published ${ch.name}.`); if (viewing === targetCloud) setParty(await listCloudCharacters(targetCloud)); })}>Publish</Button>
|
||||
<Button size="sm" variant="primary" disabled={busy || !charId || !targetCloud} onClick={run(async () => { const ch = (localChars ?? []).find((c) => c.id === charId); if (!ch) return; await publishCharacter(targetCloud, { id: ch.id, name: ch.name, data: JSON.stringify(ch) }); linkCharacter(ch.id, targetCloud); setMsg(`Published ${ch.name} — it now syncs when you edit it.`); if (viewing === targetCloud) setParty(await listCloudCharacters(targetCloud)); })}>Publish</Button>
|
||||
{!!charId && !!characterLinks[charId] && (
|
||||
<Button size="sm" variant="ghost" disabled={busy} title="Remove it from the shared campaign (your local copy is untouched)"
|
||||
onClick={run(async () => { await unpublishCharacter(charId); unlinkCharacter(charId); setMsg('Unpublished.'); if (viewing) setParty(await listCloudCharacters(viewing)); })}>Unpublish</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { Input, Field } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
|
||||
/** Tuning for the AI DM / AI Player. It reuses the assistant's AI key (Assistant
|
||||
* section above) and never rolls dice or edits a sheet without your approval. */
|
||||
@@ -35,12 +36,13 @@ export function DirectorSettings() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Transcript memory (turns)">
|
||||
<Input
|
||||
type="number"
|
||||
{/* NumberField keeps a focused draft, so typing "24" doesn't clamp to 4 then 44. */}
|
||||
<NumberField
|
||||
min={4}
|
||||
max={60}
|
||||
value={windowSize}
|
||||
onChange={(e) => setConfig({ windowSize: Math.max(4, Math.min(60, Number(e.target.value) || 16)) })}
|
||||
aria-label="Transcript memory (turns)"
|
||||
onChange={(windowSize) => setConfig({ windowSize })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io
|
||||
import { pickTextFile } from '@/lib/io/file';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
|
||||
import { notifyCloudAuthChanged } from '@/features/cloud/cloudAuth';
|
||||
import { AssistantSettings } from './AssistantSettings';
|
||||
import { DirectorSettings } from './DirectorSettings';
|
||||
import { CloudCampaigns } from './CloudCampaigns';
|
||||
@@ -79,8 +80,10 @@ export function SettingsPage() {
|
||||
const runRestore = async () => {
|
||||
if (!confirmRestore) return;
|
||||
try {
|
||||
await restoreBackup(confirmRestore);
|
||||
setMsg('Backup restored.');
|
||||
const summary = await restoreBackup(confirmRestore);
|
||||
setMsg(summary.dropped > 0
|
||||
? `Backup restored — ${summary.dropped} unreadable ${summary.dropped === 1 ? 'entry was' : 'entries were'} skipped.`
|
||||
: 'Backup restored.');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof BackupError ? e.message : 'Restore failed.');
|
||||
}
|
||||
@@ -199,6 +202,7 @@ function CloudSync() {
|
||||
const auth = (which: 'in' | 'up') => run(async () => {
|
||||
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
|
||||
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
|
||||
notifyCloudAuthChanged(); // sibling sections (shared campaigns, admin nav) react without a reload
|
||||
});
|
||||
const push = () => run(async () => {
|
||||
const r = await pushBackup();
|
||||
@@ -213,7 +217,7 @@ function CloudSync() {
|
||||
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
|
||||
});
|
||||
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
||||
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
||||
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); notifyCloudAuthChanged(); });
|
||||
|
||||
return (
|
||||
<SettingsCard label="Cloud sync (optional)">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Calendar, Campaign } from '@/lib/schemas';
|
||||
import { calendarRepo } from '@/lib/db/repositories';
|
||||
@@ -15,16 +15,35 @@ export function CalendarPage() {
|
||||
function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
const stored = useCalendar(campaign.id);
|
||||
const [eventTitle, setEventTitle] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Serialize writes so rapid clicks can't read the same stale base value.
|
||||
const chain = useRef(Promise.resolve());
|
||||
|
||||
// The row is created on first save; until then we work with an in-memory default.
|
||||
const calendar: Calendar = stored ?? { campaignId: campaign.id, currentDay: 0, events: [] };
|
||||
|
||||
const save = (next: Calendar) => void calendarRepo.save(next);
|
||||
const advance = (delta: number) => save({ ...calendar, currentDay: calendar.currentDay + delta });
|
||||
const addEvent = () => {
|
||||
if (eventTitle.trim() === '') return;
|
||||
save({ ...calendar, events: [...calendar.events, { id: newId(), day: calendar.currentDay, title: eventTitle.trim() }] });
|
||||
setEventTitle('');
|
||||
/** Fresh read-modify-write, queued behind any in-flight save. Resolves true on success. */
|
||||
const mutate = (fn: (c: Calendar) => Calendar): Promise<boolean> => {
|
||||
const run = chain.current.then(async () => {
|
||||
try {
|
||||
const cur = (await calendarRepo.get(campaign.id)) ?? { campaignId: campaign.id, currentDay: 0, events: [] };
|
||||
await calendarRepo.save(fn(cur));
|
||||
setError(null);
|
||||
return true;
|
||||
} catch {
|
||||
setError('Couldn’t save the calendar.');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
chain.current = run.then(() => undefined);
|
||||
return run;
|
||||
};
|
||||
|
||||
const advance = (delta: number) => void mutate((c) => ({ ...c, currentDay: c.currentDay + delta }));
|
||||
const addEvent = async () => {
|
||||
const title = eventTitle.trim().slice(0, 200); // calendarEventSchema caps titles at 200
|
||||
if (title === '') return;
|
||||
if (await mutate((c) => ({ ...c, events: [...c.events, { id: newId(), day: c.currentDay, title }] }))) setEventTitle('');
|
||||
};
|
||||
|
||||
const upcoming = [...calendar.events].sort((a, b) => a.day - b.day);
|
||||
@@ -32,6 +51,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title="Calendar" subtitle={`${campaign.name} · in-world timeline`} />
|
||||
{error && <p className="mb-3 text-sm text-danger" role="alert">{error}</p>}
|
||||
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-line bg-panel p-4">
|
||||
<Button variant="secondary" onClick={() => advance(-1)} aria-label="Previous day">−1 day</Button>
|
||||
@@ -44,8 +64,8 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Input value={eventTitle} onChange={(e) => setEventTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEvent()} placeholder={`Add event on day ${calendar.currentDay}…`} aria-label="Event title" />
|
||||
<Button variant="primary" onClick={addEvent}>Add event</Button>
|
||||
<Input value={eventTitle} maxLength={200} onChange={(e) => setEventTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void addEvent(); }} placeholder={`Add event on day ${calendar.currentDay}…`} aria-label="Event title" />
|
||||
<Button variant="primary" onClick={() => void addEvent()}>Add event</Button>
|
||||
</div>
|
||||
|
||||
{upcoming.length === 0 ? (
|
||||
@@ -56,7 +76,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
|
||||
<span className="flex-1 text-ink">{ev.title}</span>
|
||||
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
|
||||
<button className="text-muted hover:text-danger" onClick={() => void mutate((c) => ({ ...c, events: c.events.filter((e) => e.id !== ev.id) }))} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import {
|
||||
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
|
||||
ChevronRight, Shield, CheckCheck, Skull, Handshake,
|
||||
ChevronRight, Shield, CheckCheck, Skull, HeartPulse,
|
||||
} from 'lucide-react';
|
||||
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
@@ -193,14 +193,15 @@ function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem>
|
||||
);
|
||||
}
|
||||
|
||||
const NPC_DISPOSITION: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
|
||||
dead: { tone: 'ember', label: 'Hostile', Icon: Skull },
|
||||
alive: { tone: 'verdigris', label: 'Friendly', Icon: Handshake },
|
||||
// npc.status is a LIFE status (alive/dead/unknown), not a disposition — label it truthfully.
|
||||
const NPC_STATUS: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
|
||||
dead: { tone: 'ember', label: 'Dead', Icon: Skull },
|
||||
alive: { tone: 'verdigris', label: 'Alive', Icon: HeartPulse },
|
||||
unknown: { tone: 'default', label: 'Unknown', Icon: VenetianMask },
|
||||
};
|
||||
|
||||
function ThreatRow({ n }: { n: Npc }) {
|
||||
const d = NPC_DISPOSITION[n.status];
|
||||
const d = NPC_STATUS[n.status];
|
||||
const Icon = d.Icon;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -58,14 +58,21 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
||||
};
|
||||
|
||||
const exportUvtt = async (map: BattleMap) => {
|
||||
const { w, h } = await imageSize(map.image);
|
||||
const cols = Math.max(0, Math.round(w / map.gridSize));
|
||||
const rows = Math.max(0, Math.round(h / map.gridSize));
|
||||
const obj = toUvtt({
|
||||
image: map.image, gridSize: map.gridSize, cols, rows,
|
||||
walls: map.walls ?? [], doors: map.doors ?? [], lights: map.lights ?? [],
|
||||
});
|
||||
downloadText(`${safeFilename(map.name)}.uvtt`, JSON.stringify(obj));
|
||||
setError(null);
|
||||
try {
|
||||
const { w, h } = await imageSize(map.image);
|
||||
// ceil to match how the app renders the grid (MapCanvas / gridDims) — round
|
||||
// can declare one column/row too few and downstream VTTs clip the edge.
|
||||
const cols = Math.max(0, Math.ceil(w / map.gridSize));
|
||||
const rows = Math.max(0, Math.ceil(h / map.gridSize));
|
||||
const obj = toUvtt({
|
||||
image: map.image, gridSize: map.gridSize, cols, rows,
|
||||
walls: map.walls ?? [], doors: map.doors ?? [], lights: map.lights ?? [],
|
||||
});
|
||||
downloadText(`${safeFilename(map.name)}.uvtt`, JSON.stringify(obj));
|
||||
} catch {
|
||||
setError('Export failed — the map image could not be read.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -81,7 +88,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{maps.length === 0 ? (
|
||||
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
|
||||
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls and doors come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[230px_1fr]">
|
||||
<ul className="space-y-1.5">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/P
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useConfirm } from '@/components/ui/useConfirm';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function NotesPage() {
|
||||
@@ -16,7 +17,8 @@ export function NotesPage() {
|
||||
|
||||
function Notes({ campaign }: { campaign: Campaign }) {
|
||||
const notes = useNotes(campaign.id);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// The command palette can deep-link a specific note (one-shot reveal).
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() => useUiStore.getState().takePendingReveal('note'));
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const selected = notes.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Wand2, X } from 'lucide-react';
|
||||
import type { Campaign, Npc } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
@@ -11,6 +11,9 @@ import { useNpcs } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { useConfirm } from '@/components/ui/useConfirm';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const STATUS: Npc['status'][] = ['alive', 'dead', 'unknown'];
|
||||
|
||||
@@ -20,6 +23,8 @@ export function NpcsPage() {
|
||||
|
||||
function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
const npcs = useNpcs(campaign.id);
|
||||
// The command palette can deep-link a specific NPC (one-shot reveal).
|
||||
const [revealId] = useState<string | null>(() => useUiStore.getState().takePendingReveal('npc'));
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = npcs
|
||||
.filter((n) => n.name.toLowerCase().includes(query.toLowerCase()) || n.faction.toLowerCase().includes(query.toLowerCase()))
|
||||
@@ -38,7 +43,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
<>
|
||||
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
|
||||
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} revealed={n.id === revealId} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -46,9 +51,15 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||
function NpcCard({ npc, campaign, revealed = false }: { npc: Npc; campaign: Campaign; revealed?: boolean }) {
|
||||
const [n, setN] = useState(npc);
|
||||
// Scroll the deep-linked card into view once, with a brief highlight.
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (revealed) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [revealed]);
|
||||
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
|
||||
const { confirm, confirmElement } = useConfirm();
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
@@ -75,12 +86,18 @@ function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||
}
|
||||
if (!result) result = fallbackNpc();
|
||||
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
|
||||
update({ description: desc, role: result.role || n.role });
|
||||
// Keep the generated persona's name — the description is written around it.
|
||||
const name = result.name.trim().slice(0, 120);
|
||||
update({
|
||||
...(name ? { name } : {}),
|
||||
description: desc.slice(0, 20000),
|
||||
role: (result.role || n.role).slice(0, 120),
|
||||
});
|
||||
setGenState('idle');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div ref={cardRef} className={cn('rounded-lg border bg-panel p-4', revealed ? 'border-accent ring-1 ring-accent/40' : 'border-line')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-display font-semibold" value={n.name} onChange={(e) => update({ name: e.target.value })} aria-label="NPC name" />
|
||||
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
|
||||
@@ -89,8 +106,9 @@ function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
|
||||
<Wand2 size={13} aria-hidden />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={async () => { if (await confirm({ title: 'Delete NPC?', message: <>Delete <strong className="text-ink">{n.name || 'this NPC'}</strong>? This can’t be undone.</> })) void npcsRepo.remove(npc.id); }} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
{confirmElement}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
|
||||
<Input className="text-xs" value={n.location} onChange={(e) => update({ location: e.target.value })} placeholder="Location" aria-label="Location" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||||
import { questsRepo } from '@/lib/db/repositories';
|
||||
@@ -17,6 +17,7 @@ import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/P
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { useConfirm } from '@/components/ui/useConfirm';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
|
||||
@@ -35,13 +36,17 @@ function Quests({ campaign }: { campaign: Campaign }) {
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
||||
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
||||
// The command palette can deep-link a specific quest (one-shot reveal).
|
||||
const [revealId] = useState<string | null>(() => useUiStore.getState().takePendingReveal('quest'));
|
||||
const [genBusy, setGenBusy] = useState(false);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
|
||||
const generateQuest = async () => {
|
||||
setGenBusy(true);
|
||||
setGenError(null);
|
||||
try {
|
||||
let hook: QuestHook | null = null;
|
||||
if (canUseLlm) {
|
||||
@@ -51,12 +56,20 @@ function Quests({ campaign }: { campaign: Campaign }) {
|
||||
if (res.ok && 'data' in res) hook = res.data;
|
||||
}
|
||||
if (!hook) hook = fallbackQuestHook();
|
||||
const created = await questsRepo.create(campaign.id, hook.title);
|
||||
// The LLM's shapes are loose — clamp everything to questSchema bounds so
|
||||
// create/update can't throw on an empty or over-long field.
|
||||
const title = hook.title.trim().slice(0, 200) || 'Untitled quest';
|
||||
const created = await questsRepo.create(campaign.id, title);
|
||||
await questsRepo.update(created.id, {
|
||||
description: hook.description,
|
||||
reward: hook.reward,
|
||||
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
|
||||
description: hook.description.slice(0, 20000),
|
||||
reward: hook.reward.slice(0, 500),
|
||||
objectives: hook.objectives
|
||||
.map((text) => text.trim().slice(0, 500))
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ id: newId(), text, done: false })),
|
||||
});
|
||||
} catch {
|
||||
setGenError('Quest generation failed — nothing was created. Try again.');
|
||||
} finally {
|
||||
setGenBusy(false);
|
||||
}
|
||||
@@ -76,20 +89,26 @@ function Quests({ campaign }: { campaign: Campaign }) {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{genError && <p className="mb-3 text-sm text-danger" role="alert">{genError}</p>}
|
||||
{quests.length === 0 ? (
|
||||
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sorted.map((q) => <QuestCard key={q.id} quest={q} />)}
|
||||
{sorted.map((q) => <QuestCard key={q.id} quest={q} revealed={q.id === revealId} />)}
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestCard({ quest }: { quest: Quest }) {
|
||||
function QuestCard({ quest, revealed = false }: { quest: Quest; revealed?: boolean }) {
|
||||
const [q, setQ] = useState(quest);
|
||||
const [newObj, setNewObj] = useState('');
|
||||
// Scroll the deep-linked card into view once, with a brief highlight.
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (revealed) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [revealed]);
|
||||
const { confirm, confirmElement } = useConfirm();
|
||||
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
||||
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
||||
@@ -106,7 +125,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
||||
const done = q.objectives.filter((o) => o.done).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div ref={cardRef} className={cn('rounded-lg border bg-panel p-4', revealed ? 'border-accent ring-1 ring-accent/40' : 'border-line')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input className="flex-1 font-display font-semibold" value={q.title} onChange={(e) => update({ title: e.target.value })} aria-label="Quest title" />
|
||||
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Homebrew } from '@/lib/schemas';
|
||||
import { homebrewToEntry, homebrewCombatant } from './homebrew';
|
||||
|
||||
const base = { id: 'hb1', campaignId: 'c1', description: '', createdAt: 't', updatedAt: 't' };
|
||||
const make = (over: Partial<Homebrew>): Homebrew =>
|
||||
({ ...base, system: '5e', kind: 'monster', name: 'Test', fields: {}, ...over }) as Homebrew;
|
||||
|
||||
describe('homebrewToEntry', () => {
|
||||
it('aliases 5e spell fields to the dataset keys the category reads', () => {
|
||||
const e = homebrewToEntry(make({ kind: 'spell', name: 'Fire Nap', description: 'Sleepy flames.', fields: { Level: 5, School: 'Evocation' } }));
|
||||
expect(e.level_int).toBe(5);
|
||||
expect(e.school).toBe('Evocation');
|
||||
expect(e.desc).toBe('Sleepy flames.');
|
||||
expect(e.Level).toBe(5); // original keys preserved for the detail card
|
||||
});
|
||||
|
||||
it('aliases pf2e spell and monster fields', () => {
|
||||
const s = homebrewToEntry(make({ system: 'pf2e', kind: 'spell', fields: { Level: 3 } }));
|
||||
expect(s.level).toBe(3);
|
||||
const m = homebrewToEntry(make({ system: 'pf2e', kind: 'monster', fields: { AC: 18, HP: 40, Level: 2, Perception: 9 } }));
|
||||
expect(m).toMatchObject({ ac: 18, hp: 40, level: 2, perception: 9, __system: 'pf2e' });
|
||||
});
|
||||
|
||||
it('aliases 5e monster and item fields', () => {
|
||||
const m = homebrewToEntry(make({ fields: { AC: 15, HP: 22, CR: 3, Dexterity: 14 } }));
|
||||
expect(m).toMatchObject({ armor_class: 15, hit_points: 22, cr: 3, dexterity: 14 });
|
||||
const i = homebrewToEntry(make({ kind: 'item', fields: { Type: 'Wand', Rarity: 'rare' } }));
|
||||
expect(i).toMatchObject({ type: 'Wand', rarity: 'rare' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('homebrewCombatant', () => {
|
||||
it('5e: Dexterity-based initiative and CR', () => {
|
||||
const c = homebrewCombatant(homebrewToEntry(make({ fields: { AC: 15, HP: 22, CR: 3, Dexterity: 14 } })));
|
||||
expect(c).toEqual({ name: 'Test', ac: 15, hp: 22, initBonus: 2, cr: 3 });
|
||||
});
|
||||
|
||||
it('pf2e: Perception-based initiative and creature Level (feeds the encounter budget)', () => {
|
||||
const c = homebrewCombatant(homebrewToEntry(make({ system: 'pf2e', fields: { AC: 18, HP: 40, Level: 2, Perception: 9 } })));
|
||||
expect(c).toEqual({ name: 'Test', ac: 18, hp: 40, initBonus: 9, level: 2 });
|
||||
});
|
||||
|
||||
it('pf2e: falls back to a legacy CR value as the level', () => {
|
||||
const c = homebrewCombatant(homebrewToEntry(make({ system: 'pf2e', fields: { CR: 4 } })));
|
||||
expect(c.level).toBe(4);
|
||||
expect(c.cr).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults sanely when fields are blank', () => {
|
||||
const c = homebrewCombatant(homebrewToEntry(make({ fields: {} })));
|
||||
expect(c).toEqual({ name: 'Test', ac: 10, hp: 1, initBonus: 0 });
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,10 @@ export const HOMEBREW_FIELDS: Record<HomebrewKind, FieldDef[]> = {
|
||||
monster: [
|
||||
{ key: 'AC', label: 'Armor Class', type: 'number' },
|
||||
{ key: 'HP', label: 'Hit Points', type: 'number' },
|
||||
{ key: 'CR', label: 'Challenge Rating', type: 'number' },
|
||||
{ key: 'Dexterity', label: 'Dexterity', type: 'number' },
|
||||
{ key: 'CR', label: 'Challenge Rating (5e)', type: 'number' },
|
||||
{ key: 'Level', label: 'Level (PF2e)', type: 'number' },
|
||||
{ key: 'Dexterity', label: 'Dexterity (5e)', type: 'number' },
|
||||
{ key: 'Perception', label: 'Perception modifier (PF2e)', type: 'number' },
|
||||
],
|
||||
spell: [
|
||||
{ key: 'Level', label: 'Level', type: 'number' },
|
||||
@@ -42,15 +44,43 @@ export const CATEGORY_HOMEBREW_KIND: Record<string, HomebrewKind> = {
|
||||
'pf2e-creatures': 'monster', 'pf2e-spells': 'spell', 'pf2e-equipment': 'item', 'pf2e-feats': 'feat', 'pf2e-conditions': 'condition',
|
||||
};
|
||||
|
||||
/**
|
||||
* Dataset-shaped aliases for the capitalized homebrew field keys, so homebrew
|
||||
* entries participate in the category filters/sorts/spell normalizers exactly
|
||||
* like shipped data (5e reads level_int/school/cr…, PF2e reads level/ac/hp…).
|
||||
*/
|
||||
function datasetAliases(hb: Homebrew): Record<string, unknown> {
|
||||
const f = hb.fields;
|
||||
const out: Record<string, unknown> = {};
|
||||
const put = (k: string, v: unknown) => { if (v !== undefined && v !== '') out[k] = v; };
|
||||
if (hb.kind === 'monster') {
|
||||
if (hb.system === 'pf2e') {
|
||||
put('ac', f.AC); put('hp', f.HP); put('perception', f.Perception);
|
||||
put('level', f.Level ?? f.CR); // records predating the Level field used CR
|
||||
} else {
|
||||
put('armor_class', f.AC); put('hit_points', f.HP); put('cr', f.CR); put('dexterity', f.Dexterity);
|
||||
}
|
||||
} else if (hb.kind === 'spell') {
|
||||
put('school', f.School);
|
||||
if (hb.system === 'pf2e') { put('level', f.Level); put('text', hb.description); }
|
||||
else { put('level_int', f.Level); put('desc', hb.description); }
|
||||
} else if (hb.kind === 'item') {
|
||||
put('type', f.Type); put('rarity', f.Rarity);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a homebrew record into a compendium Entry (fields spread to top level). */
|
||||
export function homebrewToEntry(hb: Homebrew): Entry {
|
||||
return {
|
||||
...hb.fields,
|
||||
...datasetAliases(hb),
|
||||
name: hb.name,
|
||||
slug: `hb-${hb.id}`,
|
||||
description: hb.description,
|
||||
__homebrew: true,
|
||||
__kind: hb.kind,
|
||||
__system: hb.system,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,14 +89,16 @@ export function isHomebrew(entry: Entry): boolean {
|
||||
}
|
||||
|
||||
/** Combat stats for a homebrew monster entry. */
|
||||
export function homebrewCombatant(entry: Entry): { name: string; ac: number; hp: number; initBonus: number; cr?: number } {
|
||||
export function homebrewCombatant(entry: Entry): { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } {
|
||||
const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
const cr = entry.CR;
|
||||
const pf2e = entry.__system === 'pf2e';
|
||||
const rating = pf2e ? entry.Level ?? entry.CR : entry.CR; // PF2e rates creatures by Level, not CR
|
||||
return {
|
||||
name: entry.name,
|
||||
ac: num(entry.AC, 10),
|
||||
hp: num(entry.HP, 1),
|
||||
initBonus: abilityModifier(num(entry.Dexterity, 10)),
|
||||
...(typeof cr === 'number' ? { cr } : {}),
|
||||
// PF2e initiative is a Perception check; 5e initiative is Dexterity-based.
|
||||
initBonus: pf2e ? num(entry.Perception, 0) : abilityModifier(num(entry.Dexterity, 10)),
|
||||
...(typeof rating === 'number' ? (pf2e ? { level: rating } : { cr: rating }) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ const MAX_DPR = 2.5;
|
||||
*/
|
||||
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, activeTokenId, onReady }: Props) {
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -98,9 +99,12 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
|
||||
// Load the image bitmap (used both for sizing and for canvas drawImage).
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
if (!view.image) { setNatural(null); imgRef.current = null; return; }
|
||||
const img = new Image();
|
||||
img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); };
|
||||
// A corrupt/undecodable data URL must surface an error, not eternal "Loading".
|
||||
img.onerror = () => { imgRef.current = null; setNatural(null); setImgError(true); };
|
||||
img.src = view.image;
|
||||
}, [view.image]);
|
||||
|
||||
@@ -189,9 +193,11 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
<div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
|
||||
{!view.image
|
||||
? <p className="p-3 text-sm text-muted">No image.</p>
|
||||
: !natural
|
||||
? <p className="p-3 text-sm text-muted">Loading map…</p>
|
||||
: null}
|
||||
: imgError
|
||||
? <p className="p-3 text-sm text-danger">Couldn’t load the map image — it may be corrupt. Try re-importing the map.</p>
|
||||
: !natural
|
||||
? <p className="p-3 text-sm text-muted">Loading map…</p>
|
||||
: null}
|
||||
|
||||
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
|
||||
|
||||
@@ -261,8 +267,15 @@ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | nul
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, 0, 0, p.W, p.H);
|
||||
|
||||
// Drawings (world space; crisp under the camera transform). For players they
|
||||
// must go UNDER the opaque fog so annotations never leak from unrevealed
|
||||
// areas; the GM's translucent fog keeps them readable when drawn on top.
|
||||
const paintDrawings = () => { cam(); for (const d of p.drawings) drawShape(ctx, d); };
|
||||
if (p.playerFog) paintDrawings();
|
||||
|
||||
// Fog (world space)
|
||||
if (p.fogEnabled) {
|
||||
cam();
|
||||
const revealed = new Set(p.revealed);
|
||||
ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)';
|
||||
for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) {
|
||||
@@ -294,9 +307,7 @@ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | nul
|
||||
}
|
||||
}
|
||||
|
||||
// Drawings (world space; crisp under the camera transform)
|
||||
cam();
|
||||
for (const d of p.drawings) drawShape(ctx, d);
|
||||
if (!p.playerFog) paintDrawings();
|
||||
|
||||
// Walls (GM only — faint, so imported line-of-sight is visible while editing)
|
||||
if (p.walls?.length) {
|
||||
@@ -425,8 +436,9 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, active, onMove,
|
||||
ref.current?.releasePointerCapture(e.pointerId);
|
||||
const moved = drag.current.moved;
|
||||
if (moved && onMove) {
|
||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
|
||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
|
||||
// Clamp the top-left cell so the whole NxN footprint stays on the map.
|
||||
const col = Math.max(0, Math.min(cols - token.size, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
|
||||
const row = Math.max(0, Math.min(rows - token.size, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
|
||||
onMove(token.id, col, row);
|
||||
} else if (!moved && onClick) onClick(token.id);
|
||||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
||||
|
||||
@@ -44,12 +44,21 @@ function distToSegment(p: Point, a: Point, b: Point): number {
|
||||
type FogShape = 'brush' | 'rect' | 'poly';
|
||||
type AoeShape = 'circle' | 'cone' | 'line' | 'square';
|
||||
type DrawKind = MapDrawing['kind'];
|
||||
type WallMode = 'draw' | 'door' | 'erase';
|
||||
|
||||
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
|
||||
const [m, setM] = useState<BattleMap>(map);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// A rejected save (schema violation) must be surfaced — every later save of the
|
||||
// full map fails too, silently losing all work since the bad edit.
|
||||
const save = useDebouncedCallback((next: BattleMap) => {
|
||||
mapsRepo.save(next).then(
|
||||
() => setSaveError(null),
|
||||
() => setSaveError('Map changes are NOT being saved — make sure the map has a name.'),
|
||||
);
|
||||
}, 400);
|
||||
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
||||
|
||||
const [tool, setTool] = useState<Tool>('move');
|
||||
@@ -62,6 +71,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
const [drawKind, setDrawKind] = useState<DrawKind>('freehand');
|
||||
const [drawColor, setDrawColor] = useState('#d4af37');
|
||||
const [gmDraw, setGmDraw] = useState(true);
|
||||
const [wallMode, setWallMode] = useState<WallMode>('draw');
|
||||
const [doorDraft, setDoorDraft] = useState<Point | null>(null);
|
||||
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
||||
const [editToken, setEditToken] = useState<string | null>(null);
|
||||
// Collapsed by default on small screens so the map gets the width.
|
||||
@@ -115,17 +126,23 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
} else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) {
|
||||
setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) });
|
||||
} else if (tool === 'walls') {
|
||||
setOverlay({ poly: { points: poly, cursor: p.point } });
|
||||
if (wallMode === 'door' && doorDraft) setOverlay({ line: { a: doorDraft, b: p.point } });
|
||||
else setOverlay({ poly: { points: poly, cursor: p.point } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tool === 'walls') {
|
||||
if (phase === 'down') {
|
||||
if (toggleDoorNear(p.point)) return; // click a door to open/close it
|
||||
const next = [...poly, p.point];
|
||||
setPoly(next);
|
||||
setOverlay({ poly: { points: next, cursor: p.point } });
|
||||
if (phase !== 'down') return;
|
||||
if (wallMode === 'erase') { eraseNear(p.point); return; }
|
||||
if (wallMode === 'door') {
|
||||
if (!doorDraft) { setDoorDraft(p.point); setOverlay({ line: { a: p.point, b: p.point } }); }
|
||||
else { update({ doors: [...m.doors, { id: newId(), a: doorDraft, b: p.point, open: false }] }); setDoorDraft(null); setOverlay({}); }
|
||||
return;
|
||||
}
|
||||
if (toggleDoorNear(p.point)) return; // click a door to open/close it
|
||||
const next = [...poly, p.point];
|
||||
setPoly(next);
|
||||
setOverlay({ poly: { points: next, cursor: p.point } });
|
||||
return;
|
||||
}
|
||||
const reveal = tool === 'reveal';
|
||||
@@ -161,7 +178,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
let cells: string[] = [];
|
||||
if (aoeShape === 'circle') cells = circleCells(p.point, aoeFeet, gridSpec());
|
||||
else if (aoeShape === 'square') cells = squareCells(p.point, aoeFeet, gridSpec());
|
||||
else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, 53, gridSpec());
|
||||
// 5e cones are as wide as they are long (≈53°); PF2e cones are quarter circles (90°).
|
||||
else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, distMode === 'pf2e' ? 90 : 53, gridSpec());
|
||||
else cells = lineCells(origin, p.point, 5, gridSpec());
|
||||
setOverlay({ cells });
|
||||
if (phase === 'up' && aoeShape !== 'circle' && aoeShape !== 'square') anchor.current = null;
|
||||
@@ -182,11 +200,10 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ping is a GM-local marker; the sync wire schema has no ping message yet.
|
||||
if (tool === 'ping' && phase === 'down') {
|
||||
const at = p.point;
|
||||
setOverlay({ cells: [`${p.col},${p.row}`] });
|
||||
window.setTimeout(() => setOverlay((o) => (o.cells?.[0] === `${p.col},${p.row}` ? {} : o)), 1500);
|
||||
void at;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,14 +218,31 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
let bestI = -1, best = thresh;
|
||||
m.doors.forEach((d, i) => { const dist = distToSegment(p, d.a, d.b); if (dist < best) { best = dist; bestI = i; } });
|
||||
if (bestI < 0) return false;
|
||||
update({ doors: m.doors.map((d, i) => (i === bestI ? { ...d, open: !d.open } : d)) });
|
||||
const doors = m.doors.map((d, i) => (i === bestI ? { ...d, open: !d.open } : d));
|
||||
// Opening/closing a door is THE dynamic-vision event — recompute now, not
|
||||
// whenever a token next happens to move.
|
||||
update({ doors, ...(m.dynamicVision ? { revealed: visionReveal(m.tokens, m.revealed, doors) } : {}) });
|
||||
return true;
|
||||
};
|
||||
/** Delete the single wall polyline or door nearest the click (erase mode). */
|
||||
const eraseNear = (p: Point) => {
|
||||
const thresh = m.gridSize * 0.5;
|
||||
let bestWall = -1, bestDoor = -1, best = thresh;
|
||||
m.walls.forEach((w, i) => {
|
||||
for (let j = 0; j + 1 < w.points.length; j++) {
|
||||
const dist = distToSegment(p, w.points[j]!, w.points[j + 1]!);
|
||||
if (dist < best) { best = dist; bestWall = i; bestDoor = -1; }
|
||||
}
|
||||
});
|
||||
m.doors.forEach((d, i) => { const dist = distToSegment(p, d.a, d.b); if (dist < best) { best = dist; bestDoor = i; bestWall = -1; } });
|
||||
if (bestDoor >= 0) update({ doors: m.doors.filter((_, i) => i !== bestDoor) });
|
||||
else if (bestWall >= 0) update({ walls: m.walls.filter((_, i) => i !== bestWall) });
|
||||
};
|
||||
/** Cells visible to the party (pc tokens), honouring walls + closed doors. */
|
||||
const visionReveal = (tokens: MapToken[], revealed: string[]): string[] => {
|
||||
const visionReveal = (tokens: MapToken[], revealed: string[], doors = m.doors): string[] => {
|
||||
const viewers = tokens.filter((t) => t.kind === 'pc').map((t) => ({ x: (t.col + t.size / 2) * m.gridSize, y: (t.row + t.size / 2) * m.gridSize }));
|
||||
if (viewers.length === 0 || dims.cols === 0) return revealed;
|
||||
const segments = blockingSegments({ walls: m.walls, doors: m.doors });
|
||||
const segments = blockingSegments({ walls: m.walls, doors });
|
||||
const radiusPx = m.sightRadiusFeet > 0 ? (m.sightRadiusFeet / m.gridUnit.feet) * m.gridSize : 0;
|
||||
const vis = computeVisibleCells({ viewers, segments, gridSize: m.gridSize, cols: dims.cols, rows: dims.rows, radiusPx });
|
||||
return applyReveal(revealed, [...vis]);
|
||||
@@ -222,7 +256,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
||||
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
||||
const addTextLabel = () => {
|
||||
const t = textValue.trim();
|
||||
const t = textValue.trim().slice(0, 200); // drawingSchema caps text at 200
|
||||
if (t && textDraft) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [textDraft], color: drawColor, width: 4, text: t, gmOnly: gmDraw }] });
|
||||
setTextDraft(null); setTextValue('');
|
||||
};
|
||||
@@ -251,19 +285,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
{saveError && <p className="mb-2 rounded-md border border-danger/50 bg-danger/10 px-3 py-1.5 text-sm text-danger" role="alert">{saveError}</p>}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2 text-sm paper-grain">
|
||||
{!showPalette && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>
|
||||
Tokens <ChevronRight size={14} aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
<Input className="h-8 max-w-36 font-display" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||
<Input className="h-8 max-w-36 font-display" value={m.name} maxLength={120} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||
<span className="mx-1 h-5 w-px bg-line" aria-hidden />
|
||||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => {
|
||||
const ToolIcon = TOOL_ICON[t];
|
||||
return (
|
||||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">
|
||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; setDoorDraft(null); }} className="capitalize">
|
||||
<ToolIcon size={15} aria-hidden />
|
||||
{t}
|
||||
</Button>
|
||||
@@ -276,7 +311,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
|
||||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
|
||||
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
|
||||
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
|
||||
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={2048} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
|
||||
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
|
||||
|
||||
{(tool === 'reveal' || tool === 'hide') && (
|
||||
@@ -316,8 +351,18 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
{tool === 'walls' && (
|
||||
<>
|
||||
<span className="mx-1 h-4 w-px bg-line" />
|
||||
<span className="text-[11px]">Click to add wall points; click a door to open/close.</span>
|
||||
<Button size="sm" variant="secondary" disabled={poly.length < 2} onClick={finishWall}>Finish wall ({poly.length})</Button>
|
||||
{(['draw', 'door', 'erase'] as WallMode[]).map((wm) => (
|
||||
<Button key={wm} size="sm" variant={wallMode === wm ? 'primary' : 'ghost'} onClick={() => { setWallMode(wm); setPoly([]); setOverlay({}); setDoorDraft(null); }} className="capitalize">{wm}</Button>
|
||||
))}
|
||||
{wallMode === 'draw' && (
|
||||
<>
|
||||
<span className="text-[11px]">Click to add wall points; click a door to open/close.</span>
|
||||
<Button size="sm" variant="secondary" disabled={poly.length < 2} onClick={finishWall}>Finish wall ({poly.length})</Button>
|
||||
<Button size="sm" variant="ghost" disabled={m.walls.length === 0} onClick={() => update({ walls: m.walls.slice(0, -1) })}>Undo wall</Button>
|
||||
</>
|
||||
)}
|
||||
{wallMode === 'door' && <span className="text-[11px]">{doorDraft ? 'Click the other end of the door.' : 'Click where the door starts.'}</span>}
|
||||
{wallMode === 'erase' && <span className="text-[11px]">Click a wall or door to delete it.</span>}
|
||||
<Button size="sm" variant="ghost" disabled={m.walls.length === 0} onClick={() => update({ walls: [] })}>Clear walls</Button>
|
||||
<label className="flex items-center gap-1">Sight ft<NumberField className="w-14" value={m.sightRadiusFeet} min={0} max={500} onChange={(v) => update({ sightRadiusFeet: v })} aria-label="Sight radius feet" /></label>
|
||||
<Button size="sm" variant="secondary" onClick={revealFromParty}>Reveal from party</Button>
|
||||
@@ -364,7 +409,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
footer={<><Button variant="ghost" onClick={() => setEditToken(null)}>Done</Button><Button variant="danger" onClick={() => { removeToken(token.id); setEditToken(null); }}>Delete</Button></>}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-xs text-muted">Label<Input value={token.label} onChange={(e) => patchToken(token.id, { label: e.target.value })} /></label>
|
||||
<label className="block text-xs text-muted">Label<Input value={token.label} maxLength={40} onChange={(e) => patchToken(token.id, { label: e.target.value.slice(0, 40) })} /></label>
|
||||
<label className="block text-xs text-muted">Size<Select value={token.size} onChange={(e) => patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => <option key={s} value={s}>{s}×{s}</option>)}</Select></label>
|
||||
<label className="block text-xs text-muted">Kind<Select value={token.kind} onChange={(e) => patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => <option key={k} value={k}>{k}</option>)}</Select></label>
|
||||
<label className="block text-xs text-muted">Link character<Select value={token.characterId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}><option value="">— none —</option>{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
|
||||
@@ -411,7 +456,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
<Button variant="ghost" onClick={() => { setTextDraft(null); setTextValue(''); }}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!textValue.trim()} onClick={addTextLabel}>Add</Button>
|
||||
</>}>
|
||||
<Input autoFocus value={textValue} onChange={(e) => setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" />
|
||||
<Input autoFocus value={textValue} maxLength={200} onChange={(e) => setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import { unplacedPcs } from './tokens';
|
||||
import { mapTokenSchema } from '@/lib/schemas';
|
||||
import { baseSpec, unplacedPcs } from './tokens';
|
||||
|
||||
const pc = (id: string): Character => ({ id, name: id } as unknown as Character);
|
||||
|
||||
@@ -17,3 +18,11 @@ describe('unplacedPcs', () => {
|
||||
expect(unplacedPcs(pcs, [{ characterId: 'a' }, { characterId: 'b' }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseSpec', () => {
|
||||
it('clamps over-long character names to the 40-char token label cap', () => {
|
||||
const spec = baseSpec({ label: 'x'.repeat(120) });
|
||||
expect(spec.label).toHaveLength(40);
|
||||
expect(mapTokenSchema.safeParse({ id: 't', ...spec }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ export const KIND_COLOR: Record<MapToken['kind'], string> = {
|
||||
};
|
||||
|
||||
export function baseSpec(over: Partial<TokenSpec>): TokenSpec {
|
||||
return { label: '', color: '#d4af37', col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false, ...over };
|
||||
const spec = { label: '', color: '#d4af37', col: 0, row: 0, size: 1, kind: 'npc' as const, conditions: [], gmOnly: false, ...over };
|
||||
// Character/combatant names may run to 120 chars; mapTokenSchema caps labels
|
||||
// at 40, and an over-long label makes every subsequent map save throw.
|
||||
return { ...spec, label: spec.label.slice(0, 40) };
|
||||
}
|
||||
|
||||
/** PCs that don't already have a linked token on the map (for "+ All"). */
|
||||
|
||||
@@ -30,7 +30,7 @@ const GUIDE_5E: Record<string, TurnGuide> = {
|
||||
{ label: 'Action', desc: 'Cast a spell (Vicious Mockery to impose disadvantage costs nothing but a cantrip slot), or use Help.' },
|
||||
{ label: 'Bonus Action', desc: 'Bardic Inspiration — give a d6 to an ally; they can add it to any roll within the next 10 minutes. At level 5 it becomes a d8.' },
|
||||
{ label: 'Movement', desc: '30 ft. Stay at range — you have light armour and low hit points.' },
|
||||
{ label: 'Reaction', desc: 'Cutting Words (once per short rest, subtract your Inspiration die from an enemy\'s attack, check, or damage roll).' },
|
||||
{ label: 'Reaction', desc: 'Opportunity Attack by default. College of Lore (level 3) adds Cutting Words: expend a Bardic Inspiration use to subtract its die from an enemy\'s attack, check, or damage roll.' },
|
||||
],
|
||||
tip: 'Bardic Inspiration is your most powerful tool early on. Give it out before initiative is rolled when possible.',
|
||||
},
|
||||
@@ -45,13 +45,13 @@ const GUIDE_5E: Record<string, TurnGuide> = {
|
||||
},
|
||||
druid: {
|
||||
actions: [
|
||||
{ label: 'Action', desc: 'Cast a spell (Shillelagh makes your staff a Wisdom-based melee weapon) or use Wild Shape to become a beast.' },
|
||||
{ label: 'Bonus Action', desc: 'Wild Shape is a Bonus Action — transform mid-round without losing your main Action. Your gear melds into your new form.' },
|
||||
{ label: 'Movement', desc: '30 ft (or the Wild Shape form\'s speed — most animals are faster). Wild Shape HP is separate from yours; it\'s a free buffer.' },
|
||||
{ label: 'Action', desc: 'Cast a spell or use Wild Shape (an Action — only Circle of the Moon transforms as a Bonus Action) to become a beast.' },
|
||||
{ label: 'Bonus Action', desc: 'Shillelagh is cast as a Bonus Action — it makes your staff or club a Wisdom-based melee weapon. Your gear melds into your Wild Shape form.' },
|
||||
{ label: 'Movement', desc: '30 ft (or the Wild Shape form\'s speed — most animals are faster). Wild Shape HP is separate from yours.' },
|
||||
{ label: 'Reaction', desc: 'No unique reaction early on. Use Opportunity Attacks in melee or cast Absorb Elements to halve elemental damage.' },
|
||||
],
|
||||
upgradeAt: { level: 2, note: 'At level 2 you unlock Wild Shape. Use the CR ¼ CR ½ beast forms as frontline tanks — when they run out of HP you revert, unharmed.' },
|
||||
tip: 'Wild Shape HP is a free buffer on top of your own. Get hit in beast form, then revert and cast heals if needed.',
|
||||
upgradeAt: { level: 2, note: 'At level 2 you unlock Wild Shape: beasts up to CR ¼ with no flying or swimming speed (CR ½ at level 4). When the form drops to 0 HP you revert — excess damage carries over to your own HP.' },
|
||||
tip: 'Wild Shape HP is a buffer on top of your own (damage past the form\'s 0 carries over). Get hit in beast form, then revert and cast heals if needed.',
|
||||
},
|
||||
fighter: {
|
||||
actions: [
|
||||
@@ -68,9 +68,9 @@ const GUIDE_5E: Record<string, TurnGuide> = {
|
||||
{ label: 'Action', desc: 'Attack — use Dexterity with unarmed strikes and monk weapons (short sword, any simple weapon). Flurry of Blows can add 2 more hits per turn.' },
|
||||
{ label: 'Bonus Action', desc: 'Flurry of Blows (1 ki — hit twice), Step of the Wind (1 ki — Dash or Disengage), or Patient Defense (1 ki — Dodge).' },
|
||||
{ label: 'Movement', desc: '35 ft at level 1, increasing 5 ft every 4 levels. You don\'t need to engage by running in a straight line — use your speed creatively.' },
|
||||
{ label: 'Reaction', desc: 'Deflect Missiles — catch or redirect incoming ranged weapon attacks. At level 3: Stunning Strike (2 ki on a hit) makes a target lose their next turn on a failed Con save.' },
|
||||
{ label: 'Reaction', desc: 'Opportunity Attack. At level 3: Deflect Missiles — reduce a ranged weapon attack\'s damage by 1d10 + Dex + monk level (throw it back for 1 ki if reduced to 0).' },
|
||||
],
|
||||
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + Stunning Strike. A Stunning Strike on a melee hit removes the target\'s next action and gives advantage to all attacks against them.' },
|
||||
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + Stunning Strike (1 ki on a melee hit — Con save or stunned until the end of your next turn, giving advantage to all attacks against them).' },
|
||||
tip: 'Ki points replenish on a short rest. Use them every short rest — hoarding ki is a common new-player mistake.',
|
||||
},
|
||||
paladin: {
|
||||
@@ -141,7 +141,7 @@ const GUIDE_PF2E: Record<string, TurnGuide> = {
|
||||
barbarian: {
|
||||
actions: [
|
||||
{ label: '1 action', desc: 'Strike — make one weapon attack. In PF2e, you can Strike up to 3 times in one turn, but each additional attack takes a -5 / -10 penalty to hit.' },
|
||||
{ label: '2 actions', desc: 'Enter Rage (free the first time per encounter). Most of your power comes from raging — always start combat in Rage.' },
|
||||
{ label: '2 actions', desc: 'Rage (1 action — gain temporary HP equal to your level + Con modifier and extra damage on melee Strikes) + Strike. Always start combat in Rage.' },
|
||||
{ label: '3 actions', desc: 'Double-Strike, Sudden Charge (move + attack), or take the Intimidate action to Demoralize enemies.' },
|
||||
{ label: 'Reaction', desc: 'Attack of Opportunity (from the Reactive Strike feat — Fighters get it free; Barbarians can buy it). You can also Step away from threats as a free action when not engaged.' },
|
||||
],
|
||||
@@ -152,13 +152,13 @@ const GUIDE_PF2E: Record<string, TurnGuide> = {
|
||||
{ label: '1 action', desc: 'Strike — use your deity\'s favoured weapon for extra class synergy.' },
|
||||
{ label: '2 actions', desc: 'Raise a Shield (1 action) + Strike (1 action) = Block reaction available AND make an attack. Shield\'s AC bonus only applies when raised.' },
|
||||
{ label: '3 actions', desc: 'Move (1) + Raise Shield (1) + Strike (1) is your bread-and-butter turn.' },
|
||||
{ label: 'Reaction', desc: 'Champion\'s Reaction — Retributive Strike (Paladin) or Rescuing Reach (Liberator): when an ally within 15 ft is hit, you can interpose and reduce the damage. This is why you exist.' },
|
||||
{ label: 'Reaction', desc: 'Champion\'s Reaction — Retributive Strike (Paladin) or Liberating Step (Liberator): when an ally within 15 ft is hit, you can reduce the damage. This is why you exist.' },
|
||||
],
|
||||
tip: 'Your Champion\'s Reaction is the most powerful defensive tool in PF2e. Always stay within 15 ft of the party\'s squishiest member. Raise your Shield every single turn.',
|
||||
},
|
||||
cleric: {
|
||||
actions: [
|
||||
{ label: 'Heal / Harm', desc: 'Heal at 1 action targets only you; 2 actions targets a touched creature; 3 actions is an area burst that heals all allies and damages undead. Pick based on the situation.' },
|
||||
{ label: 'Heal / Harm', desc: 'Heal at 1 action touches one willing living creature; 2 actions works at 30-foot range and heals more; 3 actions is an area burst that heals all living allies and damages undead. Pick based on the situation.' },
|
||||
{ label: 'Cast a Spell', desc: 'Most spells take 2 actions. Divine Spellcasters have access to Harm / Heal, Bless, and weapon spells through their deity.' },
|
||||
{ label: 'Channel Smite', desc: '2 actions: Strike AND add a Harm/Heal charge to the hit (1 + spell rank damage). Extremely efficient if you want to be in melee.' },
|
||||
{ label: 'Reaction', desc: 'No class reaction at level 1. Take a shield if you want a Block reaction — it matters against single big hits.' },
|
||||
@@ -176,16 +176,16 @@ const GUIDE_PF2E: Record<string, TurnGuide> = {
|
||||
},
|
||||
ranger: {
|
||||
actions: [
|
||||
{ label: 'Hunt Prey', desc: '1 action at the start of each combat. Gives +2 to Perception checks and +2 to damage against your Prey for the whole fight. Never skip this.' },
|
||||
{ label: 'Hunt Prey', desc: '1 action at the start of each combat. Gives +2 circumstance to Perception checks to Seek and Survival checks to Track your prey; your hunter\'s edge (Precision: +1d8 on your first hit each round) keys off it. Never skip this.' },
|
||||
{ label: 'Strike', desc: '1 action. Ranger\'s Flurry (level 1 optional rule) reduces MAP on second Strike by 2 — great for two-weapon and bow builds.' },
|
||||
{ label: 'Spells', desc: 'You get a small list of ranger spells from level 1 if you take the Spellcasting archetype (or the Druidic Ranger subclass). Many have 1-minute durations — prep before combat.' },
|
||||
{ label: 'Reaction', desc: 'No class reaction at level 1. Consider the Attack of Opportunity feat chain for melee rangers. Evasive Arrow (ranged) can redirect enemy fire at level 12.' },
|
||||
],
|
||||
tip: 'Hunt Prey first, always. Your target has essentially -2 to -4 to their effective AC relative to you. If your Prey drops, use the free Hunt Prey reaction to pick a new one.',
|
||||
tip: 'Hunt Prey first, always — your hunter\'s edge and many ranger feats only work against your prey. When your prey drops, spend another action to Hunt a new one.',
|
||||
},
|
||||
rogue: {
|
||||
actions: [
|
||||
{ label: 'Strike', desc: 'Sneak Attack (+1d6 per 2 rogue levels) triggers on any flat-footed target. Flanking (you and an ally both adjacent to the target) makes the target flat-footed.' },
|
||||
{ label: 'Strike', desc: 'Sneak Attack (1d6, rising to 2d6/3d6/4d6 at levels 5/11/17) triggers on any flat-footed target. Flanking (you and an ally on opposite sides of the target) makes it flat-footed.' },
|
||||
{ label: 'Feint', desc: '1 action — Deception check vs target\'s Perception DC. On success: flat-footed to you until end of your next turn. Enables Sneak Attack solo.' },
|
||||
{ label: 'Skill Actions', desc: 'Tumble Through (Acrobatics), Steal, Disable Device, Recall Knowledge — Rogues have more skill actions than anyone. Use them every turn.' },
|
||||
{ label: 'Reaction', desc: 'Nimble Dodge (level 1) — add +2 to AC once per round as a Reaction when attacked. Better than many class reactions.' },
|
||||
@@ -214,7 +214,7 @@ const GUIDE_PF2E: Record<string, TurnGuide> = {
|
||||
actions: [
|
||||
{ label: 'Flurry of Blows', desc: '1 action — Strike twice in 1 action (first at -0, second at -4 MAP instead of the usual -5). Your signature move — use it every turn.' },
|
||||
{ label: 'Ki Strike', desc: '1 action (focus spell) — Strike + add d6 force damage. Replenish with 10-minute refocus.' },
|
||||
{ label: 'Stunning Fist', desc: '1 action (focus, 1 focus point) — Strike + Constitution save. On failure: Stunned 1 (lost 1 action next turn). An action-economy nightmare for enemies.' },
|
||||
{ label: 'Stunning Fist', desc: 'Level 2 feat, no extra cost — when both Flurry of Blows Strikes target one creature and either hits, it saves (Fortitude vs your class DC) or is Stunned 1. An action-economy nightmare for enemies.' },
|
||||
{ label: 'Reaction', desc: 'Deflect Arrow (level 2 feat) — reduce ranged weapon damage by 1d10+DEX with a Reaction. Consider the Crane Stance for +1 AC Reaction-free.' },
|
||||
],
|
||||
tip: 'Monks are an action-economy class — Flurry of Blows gives 2 Strikes for 1 action. Build your kit around actions that generate value: Flurry, a focus spell, and a positioning move.',
|
||||
|
||||
@@ -39,6 +39,9 @@ function pcDetail(c: Character): Pick<SceneActor, 'attacks' | 'spells' | 'resour
|
||||
const attacks = c.attacks.map((a) => {
|
||||
const r = sys.weaponAttack(rulesInput, {
|
||||
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus, damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
|
||||
// agile/striking matter here too: the AI player must see the real MAP and dice.
|
||||
...(a.agile !== undefined ? { agile: a.agile } : {}),
|
||||
...(a.striking !== undefined ? { striking: a.striking } : {}),
|
||||
});
|
||||
return { name: a.name, expression: `1d20${formatModifier(r.toHit)}`, damage: r.damage, damageType: a.damageType };
|
||||
});
|
||||
|
||||
@@ -78,6 +78,34 @@ describe('directorTurnSchema — DeepSeek tolerance', () => {
|
||||
expect(t.rollRequests).toEqual([]);
|
||||
expect(t.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats JSON null exactly like a missing key (never "null" / 0)', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
narration: 'x',
|
||||
speaker: null, // must NOT become the string "null"
|
||||
rollRequests: [{ actor: 'Lia', label: 'Stealth', kind: 'check', expression: '1d20+5', dc: null, against: null }],
|
||||
actions: [
|
||||
{ kind: 'condition', target: 'Lia', op: 'add', name: 'prone', value: null }, // must survive, value absent
|
||||
{ kind: 'damage', target: 'Goblin', amount: 5, damageType: null },
|
||||
{ kind: 'castSpell', caster: 'Lia', spell: 'Bless', atLevel: null },
|
||||
],
|
||||
});
|
||||
expect(t.speaker).toBeUndefined();
|
||||
expect(t.rollRequests[0]!.dc).toBeUndefined(); // not DC 0
|
||||
expect(t.rollRequests[0]!.against).toBeUndefined();
|
||||
expect(t.actions).toHaveLength(3);
|
||||
expect(t.actions[0]).toEqual({ kind: 'condition', target: 'Lia', op: 'add', name: 'prone' });
|
||||
expect(t.actions[1]).toEqual({ kind: 'damage', target: 'Goblin', amount: 5 });
|
||||
expect(t.actions[2]).toEqual({ kind: 'castSpell', caster: 'Lia', spell: 'Bless' });
|
||||
});
|
||||
|
||||
it('drops a damage action whose amount is null instead of proposing 0 damage', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
narration: 'x',
|
||||
actions: [{ kind: 'damage', target: 'Goblin', amount: null }, { kind: 'advanceTurn' }],
|
||||
});
|
||||
expect(t.actions.map((a) => a.kind)).toEqual(['advanceTurn']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTurn — anti-hallucination gate', () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { DirectorScene, SceneActor } from './types';
|
||||
|
||||
/** The exact JSON shape we want back, described for the model (DeepSeek-proofing). */
|
||||
const SHAPE = `Respond with ONE JSON object, no prose, no markdown fences:
|
||||
/** The exact JSON shape we want back, described for the model (DeepSeek-proofing).
|
||||
* Every schema action kind is documented — an undocumented kind is one the model
|
||||
* will never emit, silently stranding that mechanic (slots, resources, temp HP). */
|
||||
function shapeFor(scene: DirectorScene): string {
|
||||
const addLine = scene.addCandidates.length
|
||||
? `\n { "kind": "addCombatant", "name": string }, // a monster from the candidate list only`
|
||||
: '';
|
||||
return `Respond with ONE JSON object, no prose, no markdown fences:
|
||||
{
|
||||
"narration": string, // vivid prose for the table (2-4 sentences)
|
||||
"speaker": string, // optional: the NPC/PC name speaking, if any
|
||||
@@ -13,13 +19,19 @@ const SHAPE = `Respond with ONE JSON object, no prose, no markdown fences:
|
||||
],
|
||||
"actions": [ // typed state changes a human will APPROVE — you never apply them
|
||||
{ "kind": "damage", "target": string, "amount": number, "damageType": string },
|
||||
{ "kind": "heal", "target": string, "amount": number },
|
||||
{ "kind": "tempHp", "target": string, "amount": number },
|
||||
{ "kind": "condition", "target": string, "op": "add"|"remove", "name": string, "value": number },
|
||||
{ "kind": "castSpell", "caster": string, "spell": string, "atLevel": number }, // spends the slot when approved
|
||||
{ "kind": "spendResource", "actor": string, "resource": string, "amount": number },${addLine}
|
||||
{ "kind": "advanceTurn" },
|
||||
{ "kind": "log", "text": string }
|
||||
],
|
||||
"suggestions": [string] // 2-4 short next-step options for the human
|
||||
}
|
||||
Numbers are plain integers (5, not "5"). Omit arrays you don't need (use []).`;
|
||||
Numbers are plain integers (5, not "5"). Omit optional fields and empty arrays entirely — never send null.
|
||||
When your character casts a spell or uses a limited resource, ALWAYS include the matching castSpell/spendResource action so the table's tracking stays true.`;
|
||||
}
|
||||
|
||||
function actorLine(a: SceneActor): string {
|
||||
const bits: string[] = [`${a.name} (${a.kind})`];
|
||||
@@ -64,11 +76,16 @@ function serializeScene(scene: DirectorScene): string {
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
const RULES = [
|
||||
'You NEVER roll dice. When a roll is due, emit a rollRequest with the dice expression and DC; a human rolls it and reports the result back to you.',
|
||||
'You NEVER change game state directly. Propose typed actions; a human approves each one before it takes effect.',
|
||||
'Only reference creatures, PCs, NPCs, and monsters listed in the roster, by their EXACT names. Do not invent entities, locations as characters, or stats. To add a monster, use an addCombatant action with a name from the candidate list only.',
|
||||
].join(' ');
|
||||
function rulesFor(scene: DirectorScene): string {
|
||||
return [
|
||||
'You NEVER roll dice. When a roll is due, emit a rollRequest with the dice expression and DC; a human rolls it and reports the result back to you.',
|
||||
'You NEVER change game state directly. Propose typed actions; a human approves each one before it takes effect.',
|
||||
'Only reference creatures, PCs, NPCs, and monsters listed in the roster, by their EXACT names. Do not invent entities, locations as characters, or stats.' +
|
||||
// Only advertise addCombatant when there IS a grounded candidate list — otherwise
|
||||
// the sanitizer drops every such action and the model gets no feedback.
|
||||
(scene.addCandidates.length ? ' To add a monster, use an addCombatant action with a name from the candidate list only.' : ''),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function personaRole(scene: DirectorScene): string {
|
||||
if (scene.persona === 'player') {
|
||||
@@ -102,12 +119,12 @@ export function buildDirectorPrompt(scene: DirectorScene): { system: string; use
|
||||
'',
|
||||
personaRole(scene),
|
||||
'',
|
||||
RULES,
|
||||
rulesFor(scene),
|
||||
style,
|
||||
'',
|
||||
serializeScene(scene),
|
||||
'',
|
||||
SHAPE,
|
||||
shapeFor(scene),
|
||||
].join('\n');
|
||||
return { system, user: cueFor(scene) };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ function asRecord(v: unknown): Record<string, unknown> | undefined {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model emits JSON `null` for keys it doesn't use. `z.coerce` would turn that
|
||||
* into the string "null" / the number 0, so treat null exactly like a missing key
|
||||
* (optionals become undefined, required fields fail their own element's parse).
|
||||
*/
|
||||
function stripNulls(o: Record<string, unknown>): Record<string, unknown> {
|
||||
for (const k of Object.keys(o)) if (o[k] === null) delete o[k];
|
||||
return o;
|
||||
}
|
||||
|
||||
function looksLikeRoll(v: unknown): boolean {
|
||||
const o = asRecord(v);
|
||||
return !!o && ('expression' in o || 'dice' in o || 'roll' in o || 'notation' in o);
|
||||
@@ -72,7 +82,7 @@ function normalizeRoll(v: unknown): unknown {
|
||||
if (out.actor == null) out.actor = out.who ?? out.by ?? out.source ?? out.roller ?? '';
|
||||
if (out.dc == null && out.difficulty != null) out.dc = out.difficulty;
|
||||
if (out.against == null && out.vs != null) out.against = out.vs;
|
||||
return out;
|
||||
return stripNulls(out);
|
||||
}
|
||||
|
||||
function normalizeAction(v: unknown): unknown {
|
||||
@@ -93,7 +103,7 @@ function normalizeAction(v: unknown): unknown {
|
||||
if (out.resource == null) out.resource = out.resourceName ?? out.pool;
|
||||
if (out.name == null) out.name = out.condition ?? out.monster ?? out.creature;
|
||||
if (out.text == null) out.text = out.message ?? out.note;
|
||||
return out;
|
||||
return stripNulls(out);
|
||||
}
|
||||
|
||||
function normalizeTurn(raw: unknown): unknown {
|
||||
@@ -113,7 +123,7 @@ function normalizeTurn(raw: unknown): unknown {
|
||||
out.rollRequests = (rolls ?? []).map(normalizeRoll);
|
||||
out.actions = (actions ?? []).map(normalizeAction).filter((x) => x !== null);
|
||||
out.suggestions = coerceStringArray(out.suggestions ?? out.options ?? out.choices ?? out.nextSteps);
|
||||
return out;
|
||||
return stripNulls(out);
|
||||
}
|
||||
|
||||
export const rollRequestSchema = z.object({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user