Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merge conditional class names, resolving Tailwind conflicts. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
|
||||
import {
|
||||
addCombatant,
|
||||
applyDamage,
|
||||
applyHealing,
|
||||
currentCombatant,
|
||||
endEncounter,
|
||||
moveCombatant,
|
||||
nextTurn,
|
||||
previousTurn,
|
||||
removeCombatant,
|
||||
setTempHp,
|
||||
startEncounter,
|
||||
updateCombatant,
|
||||
} from './engine';
|
||||
|
||||
function mk(id: string, initiative: number, hp = 10): Combatant {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: 'monster',
|
||||
initiative,
|
||||
ac: 12,
|
||||
hp: { current: hp, max: hp, temp: 0 },
|
||||
conditions: [],
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
function encounter(combatants: Combatant[]): Encounter {
|
||||
return {
|
||||
id: 'e1', campaignId: 'c1', name: 'Test', status: 'planning',
|
||||
round: 0, turnIndex: 0, combatants,
|
||||
createdAt: '', updatedAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('initiative & turns', () => {
|
||||
it('starts sorted by initiative descending at round 1', () => {
|
||||
const e = startEncounter(encounter([mk('a', 5), mk('b', 20), mk('c', 12)]));
|
||||
expect(e.combatants.map((c) => c.id)).toEqual(['b', 'c', 'a']);
|
||||
expect(e.round).toBe(1);
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('nextTurn wraps and increments the round', () => {
|
||||
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
|
||||
e = nextTurn(e);
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
expect(e.round).toBe(1);
|
||||
e = nextTurn(e);
|
||||
expect(currentCombatant(e)?.id).toBe('a');
|
||||
expect(e.round).toBe(2);
|
||||
});
|
||||
|
||||
it('previousTurn wraps back and decrements the round', () => {
|
||||
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
|
||||
e = previousTurn(e);
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
expect(e.round).toBe(1); // never below 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', () => {
|
||||
it('removing a combatant BEFORE the current turn keeps the same active combatant', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
|
||||
e = nextTurn(e); // current = b
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
e = removeCombatant(e, 'a'); // remove someone earlier in order
|
||||
expect(currentCombatant(e)?.id).toBe('b'); // still b's turn
|
||||
});
|
||||
|
||||
it('removing the CURRENT combatant passes the turn to the next', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
|
||||
e = nextTurn(e); // current = b
|
||||
e = removeCombatant(e, 'b');
|
||||
expect(currentCombatant(e)?.id).toBe('c');
|
||||
});
|
||||
|
||||
it('removing the last combatant in order wraps to top and bumps round', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
|
||||
e = nextTurn(e); e = nextTurn(e); // current = c (last)
|
||||
e = removeCombatant(e, 'c');
|
||||
expect(currentCombatant(e)?.id).toBe('a');
|
||||
expect(e.round).toBe(2);
|
||||
});
|
||||
|
||||
it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 10)]));
|
||||
e = nextTurn(e); // current = b
|
||||
e = addCombatant(e, mk('c', 25)); // inserts between a and b
|
||||
expect(e.combatants.map((x) => x.id)).toEqual(['a', 'c', 'b']);
|
||||
expect(currentCombatant(e)?.id).toBe('b'); // unchanged
|
||||
});
|
||||
|
||||
it('changing initiative re-sorts but keeps the turn anchored', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
|
||||
e = nextTurn(e); // current = b
|
||||
e = updateCombatant(e, 'c', { initiative: 99 });
|
||||
expect(e.combatants[0]?.id).toBe('c');
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('moveCombatant nudges order but keeps the turn anchored', () => {
|
||||
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
|
||||
e = nextTurn(e); // current = b
|
||||
e = moveCombatant(e, 'a', 1); // swap a and b positions
|
||||
expect(e.combatants.map((x) => x.id)).toEqual(['b', 'a', 'c']);
|
||||
expect(currentCombatant(e)?.id).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HP transitions', () => {
|
||||
it('temp HP absorbs damage first, then current can go negative (overkill visible)', () => {
|
||||
let c = setTempHp(mk('a', 0, 10), 5);
|
||||
c = applyDamage(c, 12);
|
||||
expect(c.hp.temp).toBe(0);
|
||||
expect(c.hp.current).toBe(3); // 10 - (12-5)
|
||||
c = applyDamage(c, 20);
|
||||
expect(c.hp.current).toBe(-17); // overkill tracked, not clamped to 0
|
||||
});
|
||||
|
||||
it('temp HP does not stack — keeps the higher value', () => {
|
||||
let c = setTempHp(mk('a', 0, 10), 5);
|
||||
c = setTempHp(c, 3);
|
||||
expect(c.hp.temp).toBe(5);
|
||||
c = setTempHp(c, 8);
|
||||
expect(c.hp.temp).toBe(8);
|
||||
});
|
||||
|
||||
it('healing never exceeds max', () => {
|
||||
let c = mk('a', 0, 10);
|
||||
c = applyDamage(c, 6);
|
||||
c = applyHealing(c, 100);
|
||||
expect(c.hp.current).toBe(10);
|
||||
});
|
||||
|
||||
it('ignores NaN/negative inputs', () => {
|
||||
const c = mk('a', 0, 10);
|
||||
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
|
||||
expect(applyHealing(c, -5).hp.current).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
it('marks the encounter ended', () => {
|
||||
expect(endEncounter(encounter([])).status).toBe('ended');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
|
||||
|
||||
/**
|
||||
* Pure combat-state transitions. Every function returns a NEW encounter and
|
||||
* never mutates its input. The turn pointer is tracked by combatant *identity*,
|
||||
* so adding/removing/reordering combatants mid-fight never silently changes
|
||||
* whose turn it is — the bug cluster (C16–C19) that the old app never fixed.
|
||||
*/
|
||||
|
||||
export function currentCombatant(enc: Encounter): Combatant | undefined {
|
||||
return enc.combatants[enc.turnIndex];
|
||||
}
|
||||
|
||||
/** Sort by initiative (desc). JS sort is stable, so ties keep insertion order. */
|
||||
export function sortByInitiative(combatants: Combatant[]): Combatant[] {
|
||||
return [...combatants].sort((a, b) => b.initiative - a.initiative);
|
||||
}
|
||||
|
||||
function indexOfId(combatants: Combatant[], id: string | undefined): number {
|
||||
if (!id) return -1;
|
||||
return combatants.findIndex((c) => c.id === id);
|
||||
}
|
||||
|
||||
/** Reorder by initiative and start at the top of the order, round 1. */
|
||||
export function startEncounter(enc: Encounter): Encounter {
|
||||
return {
|
||||
...enc,
|
||||
status: 'active',
|
||||
round: enc.combatants.length > 0 ? 1 : 0,
|
||||
turnIndex: 0,
|
||||
combatants: sortByInitiative(enc.combatants),
|
||||
};
|
||||
}
|
||||
|
||||
export function endEncounter(enc: Encounter): Encounter {
|
||||
return { ...enc, status: 'ended' };
|
||||
}
|
||||
|
||||
/** Advance to the next combatant; wrapping past the end increments the round. */
|
||||
export function nextTurn(enc: Encounter): Encounter {
|
||||
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
|
||||
const atEnd = enc.turnIndex >= enc.combatants.length - 1;
|
||||
return {
|
||||
...enc,
|
||||
turnIndex: atEnd ? 0 : enc.turnIndex + 1,
|
||||
round: atEnd ? enc.round + 1 : enc.round,
|
||||
};
|
||||
}
|
||||
|
||||
/** Step back a turn; wrapping before the start decrements the round (min 1). */
|
||||
export function previousTurn(enc: Encounter): Encounter {
|
||||
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
|
||||
const atStart = enc.turnIndex <= 0;
|
||||
return {
|
||||
...enc,
|
||||
turnIndex: atStart ? enc.combatants.length - 1 : enc.turnIndex - 1,
|
||||
round: atStart ? Math.max(1, enc.round - 1) : enc.round,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a combatant. When the encounter is active, the new combatant is placed
|
||||
* by initiative and the turn pointer is corrected so it still points at the
|
||||
* same combatant whose turn it is.
|
||||
*/
|
||||
export function addCombatant(enc: Encounter, combatant: Combatant): Encounter {
|
||||
if (enc.status !== 'active') {
|
||||
return { ...enc, combatants: [...enc.combatants, combatant] };
|
||||
}
|
||||
const currentId = currentCombatant(enc)?.id;
|
||||
const combatants = sortByInitiative([...enc.combatants, combatant]);
|
||||
const turnIndex = Math.max(0, indexOfId(combatants, currentId));
|
||||
return { ...enc, combatants, turnIndex };
|
||||
}
|
||||
|
||||
/** Remove a combatant, keeping the turn on the correct next combatant. */
|
||||
export function removeCombatant(enc: Encounter, id: string): Encounter {
|
||||
const removedIndex = indexOfId(enc.combatants, id);
|
||||
if (removedIndex === -1) return enc;
|
||||
|
||||
const combatants = enc.combatants.filter((c) => c.id !== id);
|
||||
if (combatants.length === 0) return { ...enc, combatants, turnIndex: 0 };
|
||||
|
||||
let turnIndex = enc.turnIndex;
|
||||
let round = enc.round;
|
||||
if (removedIndex < enc.turnIndex) {
|
||||
// someone before the current turn left: shift pointer left
|
||||
turnIndex = enc.turnIndex - 1;
|
||||
} else if (removedIndex === enc.turnIndex) {
|
||||
// current combatant left: turn passes to whoever now sits at this index
|
||||
if (turnIndex >= combatants.length) {
|
||||
turnIndex = 0;
|
||||
if (enc.status === 'active') round += 1;
|
||||
}
|
||||
}
|
||||
return { ...enc, combatants, turnIndex, round };
|
||||
}
|
||||
|
||||
/** Patch a combatant by id. Re-sorts (and re-anchors the turn) if initiative changed. */
|
||||
export function updateCombatant(enc: Encounter, id: string, patch: Partial<Combatant>): Encounter {
|
||||
const idx = indexOfId(enc.combatants, id);
|
||||
if (idx === -1) return enc;
|
||||
const updated = enc.combatants.map((c) => (c.id === id ? { ...c, ...patch } : c));
|
||||
|
||||
if (patch.initiative !== undefined && enc.status === 'active') {
|
||||
const currentId = currentCombatant(enc)?.id;
|
||||
const combatants = sortByInitiative(updated);
|
||||
return { ...enc, combatants, turnIndex: Math.max(0, indexOfId(combatants, currentId)) };
|
||||
}
|
||||
return { ...enc, combatants: updated };
|
||||
}
|
||||
|
||||
/** Manually nudge a combatant up/down the order, keeping the turn anchored. */
|
||||
export function moveCombatant(enc: Encounter, id: string, direction: -1 | 1): Encounter {
|
||||
const idx = indexOfId(enc.combatants, id);
|
||||
if (idx === -1) return enc;
|
||||
const target = idx + direction;
|
||||
if (target < 0 || target >= enc.combatants.length) return enc;
|
||||
|
||||
const currentId = currentCombatant(enc)?.id;
|
||||
const combatants = [...enc.combatants];
|
||||
[combatants[idx], combatants[target]] = [combatants[target]!, combatants[idx]!];
|
||||
return { ...enc, combatants, turnIndex: Math.max(0, indexOfId(combatants, currentId)) };
|
||||
}
|
||||
|
||||
// ---------------- HP transitions ----------------
|
||||
|
||||
/** Apply damage: temporary HP absorbs first, then current (may go negative). */
|
||||
export function applyDamage(c: Combatant, amount: number): Combatant {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return c;
|
||||
const absorbed = Math.min(c.hp.temp, amount);
|
||||
const remaining = amount - absorbed;
|
||||
return {
|
||||
...c,
|
||||
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
|
||||
};
|
||||
}
|
||||
|
||||
/** Heal up to max. Does not touch temporary HP. */
|
||||
export function applyHealing(c: Combatant, amount: number): Combatant {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return c;
|
||||
return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } };
|
||||
}
|
||||
|
||||
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
|
||||
export function setTempHp(c: Combatant, temp: number): Combatant {
|
||||
if (!Number.isFinite(temp) || temp < 0) return c;
|
||||
return { ...c, hp: { ...c.hp, temp: Math.max(c.hp.temp, temp) } };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Monster, Spell, MagicItem } from './types';
|
||||
|
||||
/**
|
||||
* Lazy, cached loaders for the SRD datasets. Dynamic `import()` keeps the
|
||||
* multi-hundred-KB JSON out of the main bundle — it loads only when the user
|
||||
* opens the compendium (fixing the old app's eager MB-scale imports).
|
||||
*/
|
||||
let monstersCache: Monster[] | null = null;
|
||||
let spellsCache: Spell[] | null = null;
|
||||
let itemsCache: MagicItem[] | null = null;
|
||||
|
||||
export async function loadMonsters(): Promise<Monster[]> {
|
||||
if (!monstersCache) {
|
||||
const mod = await import('@/data/srd/monsters-srd.json');
|
||||
monstersCache = mod.default as unknown as Monster[];
|
||||
}
|
||||
return monstersCache;
|
||||
}
|
||||
|
||||
export async function loadSpells(): Promise<Spell[]> {
|
||||
if (!spellsCache) {
|
||||
const mod = await import('@/data/srd/spells-srd.json');
|
||||
spellsCache = mod.default as unknown as Spell[];
|
||||
}
|
||||
return spellsCache;
|
||||
}
|
||||
|
||||
export async function loadItems(): Promise<MagicItem[]> {
|
||||
if (!itemsCache) {
|
||||
const mod = await import('@/data/srd/magicitems-srd.json');
|
||||
itemsCache = mod.default as unknown as MagicItem[];
|
||||
}
|
||||
return itemsCache;
|
||||
}
|
||||
|
||||
/** Build a combat-ready stat block summary from a monster. */
|
||||
export function monsterToCombatant(m: Monster): {
|
||||
name: string;
|
||||
ac: number;
|
||||
hp: number;
|
||||
} {
|
||||
return {
|
||||
name: m.name,
|
||||
ac: m.armor_class ?? 10,
|
||||
hp: m.hit_points ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function crLabel(m: Monster): string {
|
||||
if (m.challenge_rating) return m.challenge_rating;
|
||||
if (m.cr === undefined) return '—';
|
||||
if (m.cr === 0.125) return '1/8';
|
||||
if (m.cr === 0.25) return '1/4';
|
||||
if (m.cr === 0.5) return '1/2';
|
||||
return String(m.cr);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Normalized shapes over the Open5e SRD data. Fields mirror the source JSON;
|
||||
* optional because the dataset is not perfectly uniform.
|
||||
*/
|
||||
export interface MonsterAction {
|
||||
name: string;
|
||||
desc: string;
|
||||
attack_bonus?: number;
|
||||
damage_dice?: string;
|
||||
damage_bonus?: number;
|
||||
}
|
||||
|
||||
export interface Monster {
|
||||
slug: string;
|
||||
name: string;
|
||||
size?: string;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
alignment?: string;
|
||||
armor_class?: number;
|
||||
armor_desc?: string | null;
|
||||
hit_points?: number;
|
||||
hit_dice?: string;
|
||||
speed?: Record<string, number> | { walk?: number; [k: string]: number | undefined };
|
||||
strength?: number;
|
||||
dexterity?: number;
|
||||
constitution?: number;
|
||||
intelligence?: number;
|
||||
wisdom?: number;
|
||||
charisma?: number;
|
||||
perception?: number;
|
||||
senses?: string;
|
||||
languages?: string;
|
||||
/** numeric challenge rating (0.25 for "1/4") */
|
||||
cr?: number;
|
||||
challenge_rating?: string;
|
||||
actions?: MonsterAction[] | string;
|
||||
special_abilities?: MonsterAction[] | string;
|
||||
legendary_actions?: MonsterAction[] | string;
|
||||
reactions?: MonsterAction[] | string;
|
||||
damage_vulnerabilities?: string;
|
||||
damage_resistances?: string;
|
||||
damage_immunities?: string;
|
||||
condition_immunities?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Spell {
|
||||
slug: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
higher_level?: string;
|
||||
level?: string;
|
||||
level_int?: number;
|
||||
school?: string;
|
||||
casting_time?: string;
|
||||
range?: string;
|
||||
duration?: string;
|
||||
concentration?: boolean | string;
|
||||
ritual?: boolean | string;
|
||||
components?: string;
|
||||
material?: string;
|
||||
dnd_class?: string;
|
||||
}
|
||||
|
||||
export interface MagicItem {
|
||||
slug: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
desc?: string;
|
||||
rarity?: string;
|
||||
requires_attunement?: string;
|
||||
}
|
||||
|
||||
export type CompendiumKind = 'monsters' | 'spells' | 'items';
|
||||
@@ -0,0 +1,32 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import type { Campaign } from '@/lib/schemas/campaign';
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import type { Encounter } from '@/lib/schemas/encounter';
|
||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||
|
||||
/**
|
||||
* Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions
|
||||
* correctly (commits before resolving), which removes the silent-data-loss
|
||||
* class of bugs the old hand-rolled adapter shipped with.
|
||||
*
|
||||
* Schema versions are additive: bump `version(n)` and add a `.upgrade()` when
|
||||
* the shape changes — never edit a past version in place.
|
||||
*/
|
||||
export class TtrpgDatabase extends Dexie {
|
||||
campaigns!: EntityTable<Campaign, 'id'>;
|
||||
characters!: EntityTable<Character, 'id'>;
|
||||
encounters!: EntityTable<Encounter, 'id'>;
|
||||
diceRolls!: EntityTable<DiceRoll, 'id'>;
|
||||
|
||||
constructor() {
|
||||
super('ttrpg-manager');
|
||||
this.version(1).stores({
|
||||
campaigns: 'id, updatedAt',
|
||||
characters: 'id, campaignId, kind',
|
||||
encounters: 'id, campaignId, status',
|
||||
diceRolls: 'id, campaignId, createdAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new TtrpgDatabase();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { db } from './db';
|
||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo } from './repositories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete();
|
||||
await db.open();
|
||||
});
|
||||
|
||||
describe('campaign cascade delete', () => {
|
||||
it('removes all child entities atomically (old app orphaned them forever)', async () => {
|
||||
const a = await campaignsRepo.create({ name: 'Curse of Strahd', system: '5e', description: '' });
|
||||
const b = await campaignsRepo.create({ name: 'Other', system: 'pf2e', description: '' });
|
||||
|
||||
await charactersRepo.create(a.id, {
|
||||
system: '5e', name: 'Ezmerelda', kind: 'pc', ancestry: 'Human', className: 'Ranger', level: 5,
|
||||
});
|
||||
await encountersRepo.create(a.id, 'Death House');
|
||||
await diceRepo.add({ campaignId: a.id, label: 'attack', expression: '1d20', total: 14, breakdown: '' });
|
||||
|
||||
// unrelated campaign's data must survive
|
||||
await charactersRepo.create(b.id, {
|
||||
system: 'pf2e', name: 'Keep me', kind: 'pc', ancestry: 'Elf', className: 'Wizard', level: 1,
|
||||
});
|
||||
|
||||
await campaignsRepo.remove(a.id);
|
||||
|
||||
expect(await campaignsRepo.get(a.id)).toBeUndefined();
|
||||
expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await diceRepo.recent(a.id)).toHaveLength(0);
|
||||
|
||||
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation on write', () => {
|
||||
it('rejects NaN HP', async () => {
|
||||
const c = await campaignsRepo.create({ name: 'C', system: '5e', description: '' });
|
||||
const enc = await encountersRepo.create(c.id, 'E');
|
||||
enc.combatants.push({
|
||||
id: 'x', name: 'Goblin', kind: 'monster', initiative: 12, ac: 15,
|
||||
hp: { current: Number.NaN, max: 7, temp: 0 }, conditions: [], notes: '',
|
||||
});
|
||||
await expect(encountersRepo.save(enc)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { db } from './db';
|
||||
import { newId } from '@/lib/ids';
|
||||
import {
|
||||
campaignSchema,
|
||||
characterSchema,
|
||||
encounterSchema,
|
||||
diceRollSchema,
|
||||
type Campaign,
|
||||
type CampaignDraft,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type DiceRoll,
|
||||
} from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// ---------------- Campaigns ----------------
|
||||
export const campaignsRepo = {
|
||||
async list(): Promise<Campaign[]> {
|
||||
const all = await db.campaigns.toArray();
|
||||
return all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
},
|
||||
|
||||
get(id: string): Promise<Campaign | undefined> {
|
||||
return db.campaigns.get(id);
|
||||
},
|
||||
|
||||
async create(draft: CampaignDraft): Promise<Campaign> {
|
||||
const ts = now();
|
||||
const campaign = campaignSchema.parse({
|
||||
...draft,
|
||||
id: newId(),
|
||||
description: draft.description ?? '',
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
await db.campaigns.add(campaign);
|
||||
return campaign;
|
||||
},
|
||||
|
||||
async update(id: string, patch: Partial<Omit<Campaign, 'id' | 'createdAt'>>): Promise<void> {
|
||||
await db.campaigns.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
|
||||
/** Delete a campaign and ALL its children atomically (real cascade). */
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.transaction('rw', db.campaigns, db.characters, db.encounters, db.diceRolls, async () => {
|
||||
await db.characters.where('campaignId').equals(id).delete();
|
||||
await db.encounters.where('campaignId').equals(id).delete();
|
||||
await db.diceRolls.where('campaignId').equals(id).delete();
|
||||
await db.campaigns.delete(id);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Characters ----------------
|
||||
export const charactersRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Character[]> {
|
||||
return db.characters.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
|
||||
get(id: string): Promise<Character | undefined> {
|
||||
return db.characters.get(id);
|
||||
},
|
||||
|
||||
async create(
|
||||
campaignId: string,
|
||||
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
|
||||
system: Character['system'];
|
||||
},
|
||||
): Promise<Character> {
|
||||
const ts = now();
|
||||
const sys = getSystem(draft.system);
|
||||
const character = characterSchema.parse({
|
||||
id: newId(),
|
||||
campaignId,
|
||||
system: draft.system,
|
||||
kind: draft.kind,
|
||||
name: draft.name,
|
||||
ancestry: draft.ancestry,
|
||||
className: draft.className,
|
||||
level: draft.level,
|
||||
abilities: sys.defaultAbilityScores(),
|
||||
hp: { current: 1, max: 1, temp: 0 },
|
||||
speed: 30,
|
||||
armorBonus: 0,
|
||||
skillRanks: {},
|
||||
saveRanks: {},
|
||||
perceptionRank: 'trained',
|
||||
notes: '',
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
await db.characters.add(character);
|
||||
return character;
|
||||
},
|
||||
|
||||
async update(id: string, patch: Partial<Omit<Character, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||
await db.characters.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.characters.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Encounters ----------------
|
||||
export const encountersRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Encounter[]> {
|
||||
return db.encounters.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
|
||||
get(id: string): Promise<Encounter | undefined> {
|
||||
return db.encounters.get(id);
|
||||
},
|
||||
|
||||
async create(campaignId: string, name: string): Promise<Encounter> {
|
||||
const ts = now();
|
||||
const encounter = encounterSchema.parse({
|
||||
id: newId(),
|
||||
campaignId,
|
||||
name,
|
||||
status: 'planning',
|
||||
round: 0,
|
||||
turnIndex: 0,
|
||||
combatants: [],
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
await db.encounters.add(encounter);
|
||||
return encounter;
|
||||
},
|
||||
|
||||
/** Persist a full encounter (combat state lives as one document). */
|
||||
async save(encounter: Encounter): Promise<void> {
|
||||
const validated = encounterSchema.parse({ ...encounter, updatedAt: now() });
|
||||
await db.encounters.put(validated);
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.encounters.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Dice rolls ----------------
|
||||
export const diceRepo = {
|
||||
async recent(campaignId: string | undefined, limit = 50): Promise<DiceRoll[]> {
|
||||
const coll = campaignId
|
||||
? db.diceRolls.where('campaignId').equals(campaignId)
|
||||
: db.diceRolls.toCollection();
|
||||
const all = await coll.toArray();
|
||||
return all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
|
||||
},
|
||||
|
||||
async add(roll: Omit<DiceRoll, 'id' | 'createdAt'>): Promise<DiceRoll> {
|
||||
const record = diceRollSchema.parse({ ...roll, id: newId(), createdAt: now() });
|
||||
await db.diceRolls.add(record);
|
||||
return record;
|
||||
},
|
||||
|
||||
async clear(campaignId?: string): Promise<void> {
|
||||
if (campaignId) await db.diceRolls.where('campaignId').equals(campaignId).delete();
|
||||
else await db.diceRolls.clear();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDice, rollDice, DiceParseError } from './notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
|
||||
describe('parseDice', () => {
|
||||
it('parses a simple die group', () => {
|
||||
expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]);
|
||||
});
|
||||
|
||||
it('defaults count to 1', () => {
|
||||
expect(parseDice('d20')).toEqual([{ kind: 'dice', sign: 1, count: 1, sides: 20 }]);
|
||||
});
|
||||
|
||||
it('parses mixed terms with signs', () => {
|
||||
expect(parseDice('2d6 + 1d4 - 2')).toEqual([
|
||||
{ kind: 'dice', sign: 1, count: 2, sides: 6 },
|
||||
{ kind: 'dice', sign: 1, count: 1, sides: 4 },
|
||||
{ kind: 'const', sign: -1, value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses keep-highest', () => {
|
||||
expect(parseDice('4d6kh3')).toEqual([
|
||||
{ kind: 'dice', sign: 1, count: 4, sides: 6, keep: { op: 'kh', n: 3 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['', 'abc', '3d', 'd', '1d6+', '0d6', '3d6kh4', '1d1'])(
|
||||
'rejects malformed input %s',
|
||||
(expr) => {
|
||||
expect(() => parseDice(expr)).toThrow(DiceParseError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('rollDice', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = rollDice('3d6+2', createRng('seed-1'));
|
||||
const b = rollDice('3d6+2', createRng('seed-1'));
|
||||
expect(a.total).toBe(b.total);
|
||||
expect(a.terms[0]!.dice.map((d) => d.value)).toEqual(b.terms[0]!.dice.map((d) => d.value));
|
||||
});
|
||||
|
||||
it('stays within bounds', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const r = rollDice('1d20', createRng(i));
|
||||
expect(r.total).toBeGreaterThanOrEqual(1);
|
||||
expect(r.total).toBeLessThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('keep-highest drops the lowest dice and excludes them from the total', () => {
|
||||
const r = rollDice('4d6kh3', createRng('chargen'));
|
||||
const dropped = r.terms[0]!.dice.filter((d) => !d.kept);
|
||||
expect(dropped).toHaveLength(1);
|
||||
const keptSum = r.terms[0]!.dice.filter((d) => d.kept).reduce((s, d) => s + d.value, 0);
|
||||
expect(r.total).toBe(keptSum);
|
||||
// the dropped die is <= every kept die
|
||||
const minKept = Math.min(...r.terms[0]!.dice.filter((d) => d.kept).map((d) => d.value));
|
||||
expect(dropped[0]!.value).toBeLessThanOrEqual(minKept);
|
||||
});
|
||||
|
||||
it('applies constant modifiers', () => {
|
||||
const r = rollDice('1d6+5', createRng(1));
|
||||
expect(r.total).toBe(r.terms[0]!.dice[0]!.value + 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Rng } from '@/lib/rng';
|
||||
import { createRng } from '@/lib/rng';
|
||||
|
||||
/** A single die-group term, e.g. `4d6kh3`. */
|
||||
export interface DiceGroup {
|
||||
kind: 'dice';
|
||||
sign: 1 | -1;
|
||||
count: number;
|
||||
sides: number;
|
||||
/** keep/drop highest/lowest, e.g. { op: 'kh', n: 3 } */
|
||||
keep?: { op: 'kh' | 'kl' | 'dh' | 'dl'; n: number };
|
||||
}
|
||||
|
||||
/** A flat constant term, e.g. `+3`. */
|
||||
export interface ConstantTerm {
|
||||
kind: 'const';
|
||||
sign: 1 | -1;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type DiceTerm = DiceGroup | ConstantTerm;
|
||||
|
||||
export interface RolledDie {
|
||||
sides: number;
|
||||
value: number;
|
||||
/** false when dropped by a keep/drop modifier */
|
||||
kept: boolean;
|
||||
}
|
||||
|
||||
export interface TermResult {
|
||||
term: DiceTerm;
|
||||
dice: RolledDie[];
|
||||
/** signed subtotal contributed by this term */
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface RollResult {
|
||||
expression: string;
|
||||
total: number;
|
||||
terms: TermResult[];
|
||||
/** human-readable breakdown, e.g. "2d20kh1 [18, 5→18] + 3 = 21" */
|
||||
breakdown: string;
|
||||
}
|
||||
|
||||
export class DiceParseError extends Error {}
|
||||
|
||||
const MAX_DICE = 1000;
|
||||
const MAX_SIDES = 1000;
|
||||
|
||||
const TERM_RE =
|
||||
/^(\d*)d(\d+)(?:(kh|kl|dh|dl)(\d+))?$/i;
|
||||
|
||||
/**
|
||||
* Parse a dice expression like `2d6+1d4+3` or `4d6kh3` into terms.
|
||||
* Throws DiceParseError on malformed input — never silently coerces to NaN
|
||||
* (a whole class of bugs in the old app).
|
||||
*/
|
||||
export function parseDice(expression: string): DiceTerm[] {
|
||||
const cleaned = expression.replace(/\s+/g, '');
|
||||
if (cleaned === '') throw new DiceParseError('Empty expression');
|
||||
|
||||
// Split into signed chunks: a leading sign is optional.
|
||||
const chunks = cleaned.match(/[+-]?[^+-]+/g);
|
||||
// Reconstructing must reproduce the input exactly; otherwise a dangling or
|
||||
// doubled operator (e.g. "1d6+", "1d6++2") was silently dropped.
|
||||
if (!chunks || chunks.join('') !== cleaned) {
|
||||
throw new DiceParseError(`Cannot parse "${expression}"`);
|
||||
}
|
||||
|
||||
const terms: DiceTerm[] = [];
|
||||
for (const raw of chunks) {
|
||||
let sign: 1 | -1 = 1;
|
||||
let body = raw;
|
||||
if (body.startsWith('+')) body = body.slice(1);
|
||||
else if (body.startsWith('-')) {
|
||||
sign = -1;
|
||||
body = body.slice(1);
|
||||
}
|
||||
if (body === '') throw new DiceParseError(`Dangling sign in "${expression}"`);
|
||||
|
||||
if (/^\d+$/.test(body)) {
|
||||
terms.push({ kind: 'const', sign, value: Number(body) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const m = TERM_RE.exec(body);
|
||||
if (!m) throw new DiceParseError(`Invalid term "${raw}" in "${expression}"`);
|
||||
const count = m[1] === '' ? 1 : Number(m[1]);
|
||||
const sides = Number(m[2]);
|
||||
if (count < 1 || count > MAX_DICE) throw new DiceParseError(`Dice count out of range in "${raw}"`);
|
||||
if (sides < 2 || sides > MAX_SIDES) throw new DiceParseError(`Die size out of range in "${raw}"`);
|
||||
|
||||
let keep: DiceGroup['keep'];
|
||||
if (m[3]) {
|
||||
const n = Number(m[4]);
|
||||
if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`);
|
||||
keep = { op: m[3].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n };
|
||||
}
|
||||
terms.push({ kind: 'dice', sign, count, sides, ...(keep ? { keep } : {}) });
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
function applyKeep(values: number[], keep: DiceGroup['keep']): boolean[] {
|
||||
const kept = values.map(() => true);
|
||||
if (!keep) return kept;
|
||||
// index list sorted by value
|
||||
const order = values.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
|
||||
let dropIdx: number[] = [];
|
||||
if (keep.op === 'kh') dropIdx = order.slice(0, values.length - keep.n).map((o) => o.i);
|
||||
else if (keep.op === 'kl') dropIdx = order.slice(keep.n).map((o) => o.i);
|
||||
else if (keep.op === 'dl') dropIdx = order.slice(0, keep.n).map((o) => o.i);
|
||||
else if (keep.op === 'dh') dropIdx = order.slice(values.length - keep.n).map((o) => o.i);
|
||||
for (const i of dropIdx) kept[i] = false;
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Roll a parsed/string expression. Pass an Rng for deterministic results. */
|
||||
export function rollDice(expression: string, rng: Rng = createRng()): RollResult {
|
||||
const terms = parseDice(expression);
|
||||
const termResults: TermResult[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const term of terms) {
|
||||
if (term.kind === 'const') {
|
||||
const subtotal = term.sign * term.value;
|
||||
total += subtotal;
|
||||
termResults.push({ term, dice: [], subtotal });
|
||||
continue;
|
||||
}
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < term.count; i++) values.push(rng.int(1, term.sides));
|
||||
const kept = applyKeep(values, term.keep);
|
||||
const dice: RolledDie[] = values.map((value, i) => ({
|
||||
sides: term.sides,
|
||||
value,
|
||||
kept: kept[i] ?? true,
|
||||
}));
|
||||
const sum = dice.reduce((acc, d) => acc + (d.kept ? d.value : 0), 0);
|
||||
const subtotal = term.sign * sum;
|
||||
total += subtotal;
|
||||
termResults.push({ term, dice, subtotal });
|
||||
}
|
||||
|
||||
return {
|
||||
expression,
|
||||
total,
|
||||
terms: termResults,
|
||||
breakdown: formatBreakdown(termResults, total),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTerm(tr: TermResult): string {
|
||||
const { term } = tr;
|
||||
if (term.kind === 'const') return String(term.value);
|
||||
const keep = term.keep ? `${term.keep.op}${term.keep.n}` : '';
|
||||
const label = `${term.count}d${term.sides}${keep}`;
|
||||
const dice = tr.dice
|
||||
.map((d) => (d.kept ? String(d.value) : `~~${d.value}~~`))
|
||||
.join(', ');
|
||||
return `${label} [${dice}]`;
|
||||
}
|
||||
|
||||
function formatBreakdown(terms: TermResult[], total: number): string {
|
||||
let out = '';
|
||||
terms.forEach((tr, idx) => {
|
||||
const sign = tr.term.sign < 0 ? ' - ' : idx === 0 ? '' : ' + ';
|
||||
out += sign + formatTerm(tr);
|
||||
});
|
||||
return `${out} = ${total}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Format a modifier with an explicit sign, e.g. 3 -> "+3", -1 -> "-1". */
|
||||
export function formatModifier(n: number): string {
|
||||
return n >= 0 ? `+${n}` : String(n);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/** Generate a collision-resistant unique id. Single strategy app-wide. */
|
||||
export function newId(): string {
|
||||
return nanoid(16);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Seedable pseudo-random number generator.
|
||||
*
|
||||
* The old app mixed `Math.random()` into game rolls, which made results
|
||||
* untestable and non-reproducible. Everything that rolls dice goes through an
|
||||
* `Rng` instance instead, so callers can pass a seed for deterministic tests
|
||||
* and reproducible "shared seed" sessions.
|
||||
*/
|
||||
export interface Rng {
|
||||
/** Float in [0, 1). */
|
||||
next(): number;
|
||||
/** Integer in [min, max] inclusive. */
|
||||
int(min: number, max: number): number;
|
||||
}
|
||||
|
||||
/** Hash a string seed into a 32-bit integer (xfnv1a). */
|
||||
function hashSeed(str: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32 — small, fast, well-distributed PRNG. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deterministic RNG from a seed. With no seed, derives one from
|
||||
* crypto for genuine unpredictability while keeping the same interface.
|
||||
*/
|
||||
export function createRng(seed?: string | number): Rng {
|
||||
let intSeed: number;
|
||||
if (seed === undefined) {
|
||||
intSeed = (globalThis.crypto?.getRandomValues(new Uint32Array(1))[0] ?? Date.now()) >>> 0;
|
||||
} else if (typeof seed === 'number') {
|
||||
intSeed = seed >>> 0;
|
||||
} else {
|
||||
intSeed = hashSeed(seed);
|
||||
}
|
||||
const gen = mulberry32(intSeed);
|
||||
return {
|
||||
next: gen,
|
||||
int(min: number, max: number): number {
|
||||
if (max < min) [min, max] = [max, min];
|
||||
return min + Math.floor(gen() * (max - min + 1));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AbilityKey, AbilityScores } from './types';
|
||||
import { ABILITY_KEYS } from './types';
|
||||
|
||||
/** Shared d20 ability modifier. Clamps absurd inputs rather than returning NaN. */
|
||||
export function abilityModifier(score: number): number {
|
||||
if (!Number.isFinite(score)) return 0;
|
||||
return Math.floor((score - 10) / 2);
|
||||
}
|
||||
|
||||
export function defaultAbilityScores(): AbilityScores {
|
||||
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||
}
|
||||
|
||||
export const ABILITY_LABELS: Record<AbilityKey, string> = {
|
||||
str: 'Strength',
|
||||
dex: 'Dexterity',
|
||||
con: 'Constitution',
|
||||
int: 'Intelligence',
|
||||
wis: 'Wisdom',
|
||||
cha: 'Charisma',
|
||||
};
|
||||
|
||||
export const ABILITY_ABBR: Record<AbilityKey, string> = {
|
||||
str: 'STR',
|
||||
dex: 'DEX',
|
||||
con: 'CON',
|
||||
int: 'INT',
|
||||
wis: 'WIS',
|
||||
cha: 'CHA',
|
||||
};
|
||||
|
||||
export { ABILITY_KEYS };
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
AbilityKey,
|
||||
CharacterRulesInput,
|
||||
DerivedSkill,
|
||||
ProficiencyRank,
|
||||
RulesSystem,
|
||||
} from '../types';
|
||||
import { ABILITY_KEYS } from '../types';
|
||||
import {
|
||||
ABILITY_ABBR,
|
||||
ABILITY_LABELS,
|
||||
abilityModifier,
|
||||
defaultAbilityScores,
|
||||
} from '../abilities';
|
||||
import { DND5E_SKILLS } from './skills';
|
||||
|
||||
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
|
||||
export function proficiencyBonus(level: number): number {
|
||||
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
|
||||
return 2 + Math.floor((lvl - 1) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* In 5e a skill/save is either proficient or not, with "expert" meaning
|
||||
* expertise (double proficiency). We map PF2e-style ranks onto that.
|
||||
*/
|
||||
function profMultiplier(rank: ProficiencyRank): number {
|
||||
if (rank === 'untrained') return 0;
|
||||
if (rank === 'trained') return 1;
|
||||
return 2; // expert/master/legendary => expertise
|
||||
}
|
||||
|
||||
export const dnd5e: RulesSystem = {
|
||||
id: '5e',
|
||||
label: 'D&D 5e',
|
||||
abilities: ABILITY_KEYS,
|
||||
abilityLabels: ABILITY_LABELS,
|
||||
skills: DND5E_SKILLS,
|
||||
|
||||
abilityModifier,
|
||||
defaultAbilityScores,
|
||||
|
||||
proficiencyValue(level, rank) {
|
||||
return proficiencyBonus(level) * profMultiplier(rank);
|
||||
},
|
||||
|
||||
initiativeModifier({ abilities }) {
|
||||
return abilityModifier(abilities.dex);
|
||||
},
|
||||
|
||||
skillModifiers(input: CharacterRulesInput): DerivedSkill[] {
|
||||
const pb = proficiencyBonus(input.level);
|
||||
return DND5E_SKILLS.map((s) => {
|
||||
const rank = input.skillRanks?.[s.key] ?? 'untrained';
|
||||
return {
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
ability: s.ability,
|
||||
modifier: abilityModifier(input.abilities[s.ability]) + pb * profMultiplier(rank),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
saveModifiers(input): Record<AbilityKey, number> {
|
||||
const pb = proficiencyBonus(input.level);
|
||||
const out = {} as Record<AbilityKey, number>;
|
||||
for (const a of ABILITY_KEYS) {
|
||||
const rank = input.saveRanks?.[a] ?? 'untrained';
|
||||
out[a] = abilityModifier(input.abilities[a]) + (rank !== 'untrained' ? pb : 0);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
baseArmorClass(input) {
|
||||
// Unarmored AC. Heavy armor etc. is represented as armorBonus on top.
|
||||
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
|
||||
},
|
||||
|
||||
spellSaveDc(input, ability) {
|
||||
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
||||
},
|
||||
};
|
||||
|
||||
export { ABILITY_ABBR };
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SkillDef } from '../types';
|
||||
|
||||
export const DND5E_SKILLS: readonly SkillDef[] = [
|
||||
{ key: 'acrobatics', label: 'Acrobatics', ability: 'dex' },
|
||||
{ key: 'animal-handling', label: 'Animal Handling', ability: 'wis' },
|
||||
{ key: 'arcana', label: 'Arcana', ability: 'int' },
|
||||
{ key: 'athletics', label: 'Athletics', ability: 'str' },
|
||||
{ key: 'deception', label: 'Deception', ability: 'cha' },
|
||||
{ key: 'history', label: 'History', ability: 'int' },
|
||||
{ key: 'insight', label: 'Insight', ability: 'wis' },
|
||||
{ key: 'intimidation', label: 'Intimidation', ability: 'cha' },
|
||||
{ key: 'investigation', label: 'Investigation', ability: 'int' },
|
||||
{ key: 'medicine', label: 'Medicine', ability: 'wis' },
|
||||
{ key: 'nature', label: 'Nature', ability: 'int' },
|
||||
{ key: 'perception', label: 'Perception', ability: 'wis' },
|
||||
{ key: 'performance', label: 'Performance', ability: 'cha' },
|
||||
{ key: 'persuasion', label: 'Persuasion', ability: 'cha' },
|
||||
{ key: 'religion', label: 'Religion', ability: 'int' },
|
||||
{ key: 'sleight-of-hand', label: 'Sleight of Hand', ability: 'dex' },
|
||||
{ key: 'stealth', label: 'Stealth', ability: 'dex' },
|
||||
{ key: 'survival', label: 'Survival', ability: 'wis' },
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RulesSystem, SystemId } from './types';
|
||||
import { dnd5e } from './dnd5e';
|
||||
import { pf2e } from './pf2e';
|
||||
|
||||
const SYSTEMS: Record<SystemId, RulesSystem> = {
|
||||
'5e': dnd5e,
|
||||
pf2e,
|
||||
};
|
||||
|
||||
export function getSystem(id: SystemId): RulesSystem {
|
||||
return SYSTEMS[id];
|
||||
}
|
||||
|
||||
export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
|
||||
{ id: '5e', label: dnd5e.label },
|
||||
{ id: 'pf2e', label: pf2e.label },
|
||||
];
|
||||
|
||||
export * from './types';
|
||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS } from './abilities';
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
AbilityKey,
|
||||
CharacterRulesInput,
|
||||
DerivedSkill,
|
||||
ProficiencyRank,
|
||||
RulesSystem,
|
||||
} from '../types';
|
||||
import { ABILITY_KEYS } from '../types';
|
||||
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores } from '../abilities';
|
||||
import { PF2E_SKILLS } from './skills';
|
||||
|
||||
const RANK_BONUS: Record<ProficiencyRank, number> = {
|
||||
untrained: 0,
|
||||
trained: 2,
|
||||
expert: 4,
|
||||
master: 6,
|
||||
legendary: 8,
|
||||
};
|
||||
|
||||
/**
|
||||
* PF2e proficiency adds your level only when trained or better, plus the rank
|
||||
* bonus. Untrained = 0 (no level). This is the core math difference from 5e.
|
||||
*/
|
||||
export function pf2eProficiency(level: number, rank: ProficiencyRank): number {
|
||||
if (rank === 'untrained') return 0;
|
||||
const lvl = Math.max(1, Math.floor(level) || 1);
|
||||
return lvl + RANK_BONUS[rank];
|
||||
}
|
||||
|
||||
export const pf2e: RulesSystem = {
|
||||
id: 'pf2e',
|
||||
label: 'Pathfinder 2e',
|
||||
abilities: ABILITY_KEYS,
|
||||
abilityLabels: ABILITY_LABELS,
|
||||
skills: PF2E_SKILLS,
|
||||
|
||||
abilityModifier,
|
||||
defaultAbilityScores,
|
||||
|
||||
proficiencyValue: pf2eProficiency,
|
||||
|
||||
initiativeModifier(input) {
|
||||
// Initiative in PF2e is usually a Perception check.
|
||||
return abilityModifier(input.abilities.wis) + pf2eProficiency(input.level, input.perceptionRank ?? 'trained');
|
||||
},
|
||||
|
||||
skillModifiers(input: CharacterRulesInput): DerivedSkill[] {
|
||||
return PF2E_SKILLS.map((s) => {
|
||||
const rank = input.skillRanks?.[s.key] ?? 'untrained';
|
||||
return {
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
ability: s.ability,
|
||||
modifier: abilityModifier(input.abilities[s.ability]) + pf2eProficiency(input.level, rank),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
saveModifiers(input): Record<AbilityKey, number> {
|
||||
const out = {} as Record<AbilityKey, number>;
|
||||
for (const a of ABILITY_KEYS) {
|
||||
// Only CON (Fort), DEX (Ref), WIS (Will) are real saves in PF2e.
|
||||
const isSave = a === 'con' || a === 'dex' || a === 'wis';
|
||||
const rank = input.saveRanks?.[a] ?? 'untrained';
|
||||
out[a] = abilityModifier(input.abilities[a]) + (isSave ? pf2eProficiency(input.level, rank) : 0);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
baseArmorClass(input) {
|
||||
// 10 + dex + (item/proficiency folded into armorBonus for the MVP sheet).
|
||||
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
|
||||
},
|
||||
|
||||
spellSaveDc(input, ability) {
|
||||
return 10 + abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SkillDef } from '../types';
|
||||
|
||||
export const PF2E_SKILLS: readonly SkillDef[] = [
|
||||
{ key: 'acrobatics', label: 'Acrobatics', ability: 'dex' },
|
||||
{ key: 'arcana', label: 'Arcana', ability: 'int' },
|
||||
{ key: 'athletics', label: 'Athletics', ability: 'str' },
|
||||
{ key: 'crafting', label: 'Crafting', ability: 'int' },
|
||||
{ key: 'deception', label: 'Deception', ability: 'cha' },
|
||||
{ key: 'diplomacy', label: 'Diplomacy', ability: 'cha' },
|
||||
{ key: 'intimidation', label: 'Intimidation', ability: 'cha' },
|
||||
{ key: 'medicine', label: 'Medicine', ability: 'wis' },
|
||||
{ key: 'nature', label: 'Nature', ability: 'wis' },
|
||||
{ key: 'occultism', label: 'Occultism', ability: 'int' },
|
||||
{ key: 'performance', label: 'Performance', ability: 'cha' },
|
||||
{ key: 'religion', label: 'Religion', ability: 'wis' },
|
||||
{ key: 'society', label: 'Society', ability: 'int' },
|
||||
{ key: 'stealth', label: 'Stealth', ability: 'dex' },
|
||||
{ key: 'survival', label: 'Survival', ability: 'wis' },
|
||||
{ key: 'thievery', label: 'Thievery', ability: 'dex' },
|
||||
];
|
||||
|
||||
/** PF2e's three saving throws and the ability each keys off. */
|
||||
export const PF2E_SAVES = [
|
||||
{ key: 'fortitude', label: 'Fortitude', ability: 'con' as const },
|
||||
{ key: 'reflex', label: 'Reflex', ability: 'dex' as const },
|
||||
{ key: 'will', label: 'Will', ability: 'wis' as const },
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { abilityModifier } from './abilities';
|
||||
import { dnd5e, proficiencyBonus } from './dnd5e';
|
||||
import { pf2e, pf2eProficiency } from './pf2e';
|
||||
import type { CharacterRulesInput } from './types';
|
||||
|
||||
describe('abilityModifier', () => {
|
||||
it.each([
|
||||
[10, 0],
|
||||
[11, 0],
|
||||
[12, 1],
|
||||
[8, -1],
|
||||
[20, 5],
|
||||
[1, -5],
|
||||
[30, 10],
|
||||
])('score %i -> %i', (score, mod) => {
|
||||
expect(abilityModifier(score)).toBe(mod);
|
||||
});
|
||||
|
||||
it('never returns NaN for garbage input', () => {
|
||||
expect(abilityModifier(Number.NaN)).toBe(0);
|
||||
expect(abilityModifier(Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5e proficiency bonus', () => {
|
||||
it.each([
|
||||
[1, 2],
|
||||
[4, 2],
|
||||
[5, 3],
|
||||
[9, 4],
|
||||
[13, 5],
|
||||
[17, 6],
|
||||
[20, 6],
|
||||
])('level %i -> +%i', (lvl, pb) => {
|
||||
expect(proficiencyBonus(lvl)).toBe(pb);
|
||||
});
|
||||
|
||||
it('clamps out-of-range levels (old app gave +251 at level 1000)', () => {
|
||||
expect(proficiencyBonus(1000)).toBe(6);
|
||||
expect(proficiencyBonus(0)).toBe(2);
|
||||
expect(proficiencyBonus(-5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5e derived stats', () => {
|
||||
const input: CharacterRulesInput = {
|
||||
level: 5,
|
||||
abilities: { str: 16, dex: 14, con: 14, int: 10, wis: 12, cha: 8 },
|
||||
skillRanks: { athletics: 'trained', stealth: 'expert' },
|
||||
saveRanks: { str: 'trained', con: 'trained' },
|
||||
};
|
||||
|
||||
it('adds proficiency to trained skills and double for expertise', () => {
|
||||
const skills = dnd5e.skillModifiers(input);
|
||||
const athletics = skills.find((s) => s.key === 'athletics')!;
|
||||
const stealth = skills.find((s) => s.key === 'stealth')!;
|
||||
const arcana = skills.find((s) => s.key === 'arcana')!;
|
||||
expect(athletics.modifier).toBe(3 + 3); // str mod + PB(5)=3
|
||||
expect(stealth.modifier).toBe(2 + 6); // dex mod + 2*PB
|
||||
expect(arcana.modifier).toBe(0); // untrained int
|
||||
});
|
||||
|
||||
it('computes spell save DC', () => {
|
||||
expect(dnd5e.spellSaveDc!(input, 'wis')).toBe(8 + 3 + 1);
|
||||
});
|
||||
|
||||
it('unarmored AC does not penalise below 10 + dex', () => {
|
||||
// regression: old app applied a phantom -1 from heavy armor DEX cap
|
||||
const ac = dnd5e.baseArmorClass(input);
|
||||
expect(ac).toBe(10 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pf2e proficiency', () => {
|
||||
it('adds level + rank bonus when trained, 0 when untrained', () => {
|
||||
expect(pf2eProficiency(5, 'untrained')).toBe(0);
|
||||
expect(pf2eProficiency(5, 'trained')).toBe(5 + 2);
|
||||
expect(pf2eProficiency(5, 'expert')).toBe(5 + 4);
|
||||
expect(pf2eProficiency(10, 'legendary')).toBe(10 + 8);
|
||||
});
|
||||
|
||||
it('skill modifier folds in ability + proficiency', () => {
|
||||
const skills = pf2e.skillModifiers({
|
||||
level: 3,
|
||||
abilities: { str: 18, dex: 10, con: 12, int: 10, wis: 14, cha: 10 },
|
||||
skillRanks: { athletics: 'expert' },
|
||||
});
|
||||
const athletics = skills.find((s) => s.key === 'athletics')!;
|
||||
expect(athletics.modifier).toBe(4 + (3 + 4)); // str mod + (level + expert bonus)
|
||||
});
|
||||
|
||||
it('only con/dex/wis are real saves', () => {
|
||||
const saves = pf2e.saveModifiers({
|
||||
level: 4,
|
||||
abilities: { str: 10, dex: 14, con: 16, int: 10, wis: 12, cha: 10 },
|
||||
saveRanks: { con: 'expert', dex: 'trained', wis: 'trained' },
|
||||
});
|
||||
expect(saves.con).toBe(3 + (4 + 4));
|
||||
expect(saves.dex).toBe(2 + (4 + 2));
|
||||
expect(saves.str).toBe(0); // not a save: just ability mod
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export type SystemId = '5e' | 'pf2e';
|
||||
|
||||
export const ABILITY_KEYS = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const;
|
||||
export type AbilityKey = (typeof ABILITY_KEYS)[number];
|
||||
|
||||
export type AbilityScores = Record<AbilityKey, number>;
|
||||
|
||||
export interface SkillDef {
|
||||
key: string;
|
||||
label: string;
|
||||
ability: AbilityKey;
|
||||
}
|
||||
|
||||
/** PF2e proficiency ranks; 5e collapses to trained/untrained semantics. */
|
||||
export type ProficiencyRank = 'untrained' | 'trained' | 'expert' | 'master' | 'legendary';
|
||||
|
||||
export interface DerivedSkill {
|
||||
key: string;
|
||||
label: string;
|
||||
ability: AbilityKey;
|
||||
modifier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract every supported game system implements. The UI and stores talk
|
||||
* to this interface only — no `if (system === '5e')` scattered through features.
|
||||
*/
|
||||
export interface RulesSystem {
|
||||
id: SystemId;
|
||||
label: string;
|
||||
abilities: readonly AbilityKey[];
|
||||
abilityLabels: Record<AbilityKey, string>;
|
||||
skills: readonly SkillDef[];
|
||||
|
||||
/** Standard d20-family ability modifier: floor((score - 10) / 2). */
|
||||
abilityModifier(score: number): number;
|
||||
|
||||
/** Default ability scores for a new character. */
|
||||
defaultAbilityScores(): AbilityScores;
|
||||
|
||||
/** Initiative modifier for a character. */
|
||||
initiativeModifier(input: CharacterRulesInput): number;
|
||||
|
||||
/** Per-skill modifiers given a character's stats. */
|
||||
skillModifiers(input: CharacterRulesInput): DerivedSkill[];
|
||||
|
||||
/** Saving-throw / defense modifiers, keyed by ability. */
|
||||
saveModifiers(input: CharacterRulesInput): Record<AbilityKey, number>;
|
||||
|
||||
/** Suggested unarmored defense/AC. */
|
||||
baseArmorClass(input: CharacterRulesInput): number;
|
||||
|
||||
/** Proficiency contribution (5e: bonus by level; PF2e: rank+level). */
|
||||
proficiencyValue(level: number, rank: ProficiencyRank): number;
|
||||
|
||||
/** Spellcasting save DC for the given casting ability, if any. */
|
||||
spellSaveDc?(input: CharacterRulesInput, ability: AbilityKey): number;
|
||||
}
|
||||
|
||||
/** Minimal character shape the rules layer needs to compute derived values. */
|
||||
export interface CharacterRulesInput {
|
||||
level: number;
|
||||
abilities: AbilityScores;
|
||||
/** rank per skill key; missing = untrained */
|
||||
skillRanks?: Record<string, ProficiencyRank>;
|
||||
/** rank per save ability; missing = untrained */
|
||||
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
|
||||
/** flat AC bonus from armor/shield/etc. */
|
||||
armorBonus?: number;
|
||||
/** PF2e: Perception proficiency (drives initiative). */
|
||||
perceptionRank?: ProficiencyRank;
|
||||
/** PF2e: spellcasting proficiency. */
|
||||
spellcastingRank?: ProficiencyRank;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
import { systemIdSchema } from './common';
|
||||
|
||||
export const campaignSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, 'Name is required').max(120),
|
||||
system: systemIdSchema,
|
||||
description: z.string().max(5000).default(''),
|
||||
/** ISO date string */
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type Campaign = z.infer<typeof campaignSchema>;
|
||||
|
||||
/** Fields a user supplies when creating a campaign. */
|
||||
export const campaignDraftSchema = campaignSchema.pick({
|
||||
name: true,
|
||||
system: true,
|
||||
description: true,
|
||||
});
|
||||
export type CampaignDraft = z.infer<typeof campaignDraftSchema>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
abilityScoresSchema,
|
||||
hpSchema,
|
||||
int,
|
||||
proficiencyRankSchema,
|
||||
systemIdSchema,
|
||||
} from './common';
|
||||
|
||||
export const characterKindSchema = z.enum(['pc', 'npc']);
|
||||
|
||||
export const characterSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
system: systemIdSchema,
|
||||
kind: characterKindSchema.default('pc'),
|
||||
|
||||
name: z.string().min(1).max(120),
|
||||
/** ancestry (PF2e) / race (5e), free text for MVP */
|
||||
ancestry: z.string().max(80).default(''),
|
||||
/** class, free text for MVP */
|
||||
className: z.string().max(80).default(''),
|
||||
level: int.min(1).max(20).default(1),
|
||||
|
||||
abilities: abilityScoresSchema,
|
||||
hp: hpSchema,
|
||||
speed: int.nonnegative().default(30),
|
||||
armorBonus: int.default(0),
|
||||
|
||||
skillRanks: z.record(z.string(), proficiencyRankSchema).default({}),
|
||||
saveRanks: z.record(z.string(), proficiencyRankSchema).default({}),
|
||||
perceptionRank: proficiencyRankSchema.default('trained'),
|
||||
spellcastingRank: proficiencyRankSchema.optional(),
|
||||
spellcastingAbility: z.enum(['int', 'wis', 'cha']).optional(),
|
||||
|
||||
notes: z.string().max(20000).default(''),
|
||||
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type Character = z.infer<typeof characterSchema>;
|
||||
|
||||
export const characterDraftSchema = characterSchema.pick({
|
||||
name: true,
|
||||
kind: true,
|
||||
ancestry: true,
|
||||
className: true,
|
||||
level: true,
|
||||
});
|
||||
export type CharacterDraft = z.infer<typeof characterDraftSchema>;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const systemIdSchema = z.enum(['5e', 'pf2e']);
|
||||
|
||||
export const proficiencyRankSchema = z.enum([
|
||||
'untrained',
|
||||
'trained',
|
||||
'expert',
|
||||
'master',
|
||||
'legendary',
|
||||
]);
|
||||
|
||||
/** A finite integer — rejects NaN and Infinity, which the old app accepted everywhere. */
|
||||
export const int = z.number().int().finite();
|
||||
/** A finite number (allows fractions, e.g. CR 1/2). */
|
||||
export const num = z.number().finite();
|
||||
|
||||
export const abilityScoresSchema = z.object({
|
||||
str: int,
|
||||
dex: int,
|
||||
con: int,
|
||||
int: int,
|
||||
wis: int,
|
||||
cha: int,
|
||||
});
|
||||
|
||||
export const hpSchema = z.object({
|
||||
current: int,
|
||||
max: int.nonnegative(),
|
||||
temp: int.nonnegative().default(0),
|
||||
});
|
||||
|
||||
export const conditionSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
/** stacking value, e.g. PF2e frightened 2 or 5e exhaustion level */
|
||||
value: int.positive().optional(),
|
||||
});
|
||||
|
||||
export type HitPoints = z.infer<typeof hpSchema>;
|
||||
export type Condition = z.infer<typeof conditionSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { num } from './common';
|
||||
|
||||
export const diceRollSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string().optional(),
|
||||
label: z.string().max(120).default(''),
|
||||
expression: z.string().min(1),
|
||||
total: num,
|
||||
breakdown: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export type DiceRoll = z.infer<typeof diceRollSchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
import { conditionSchema, hpSchema, int } from './common';
|
||||
|
||||
export const combatantKindSchema = z.enum(['pc', 'npc', 'monster']);
|
||||
|
||||
export const combatantSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
kind: combatantKindSchema,
|
||||
/** link to a Character, if this combatant is a tracked PC/NPC */
|
||||
characterId: z.string().optional(),
|
||||
/** ref into the bestiary, if spawned from a monster */
|
||||
monsterRef: z.string().optional(),
|
||||
|
||||
initiative: int,
|
||||
ac: int,
|
||||
hp: hpSchema,
|
||||
conditions: z.array(conditionSchema).default([]),
|
||||
notes: z.string().max(2000).default(''),
|
||||
});
|
||||
|
||||
export type Combatant = z.infer<typeof combatantSchema>;
|
||||
|
||||
export const encounterStatusSchema = z.enum(['planning', 'active', 'ended']);
|
||||
|
||||
export const encounterSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
status: encounterStatusSchema.default('planning'),
|
||||
round: int.nonnegative().default(0),
|
||||
/** index into combatants of whose turn it is */
|
||||
turnIndex: int.nonnegative().default(0),
|
||||
combatants: z.array(combatantSchema).default([]),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type Encounter = z.infer<typeof encounterSchema>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './common';
|
||||
export * from './campaign';
|
||||
export * from './character';
|
||||
export * from './encounter';
|
||||
export * from './dice';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Returns a debounced wrapper around `fn` that also flushes on unmount, so the
|
||||
* last edit is never lost when navigating away (the old app silently dropped
|
||||
* unsaved character changes on navigation).
|
||||
*/
|
||||
export function useDebouncedCallback<A extends unknown[]>(
|
||||
fn: (...args: A) => void,
|
||||
delay = 400,
|
||||
): (...args: A) => void {
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pending = useRef<A | null>(null);
|
||||
|
||||
const flush = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
if (pending.current) {
|
||||
fnRef.current(...pending.current);
|
||||
pending.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => flush, []); // flush on unmount
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
(...args: A) => {
|
||||
pending.current = args;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
if (pending.current) {
|
||||
fnRef.current(...pending.current);
|
||||
pending.current = null;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user