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,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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user