Build MVP: campaigns, characters, combat, dice, compendium

- Rules abstraction (5e + pf2e) behind one interface, fully tested
- Pure combat engine: turn-order safe across add/remove/reorder/init-change
- Dexie storage with real cascade deletes + Zod validation on write
- Seedable dice engine with notation parser
- Lazy SRD compendium (code-split), bestiary -> combat
- Strict TS, 54 unit tests, Playwright e2e smoke (all green)

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