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
+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();
},
};