744e91f703
- Player View (/play): read-only projector screen — party HP bars + conditions, active-encounter initiative with current turn; enemy HP hidden behind a Healthy/Bloodied/Down status band; Fullscreen button - SyncAdapter seam (src/lib/sync): local-first default via Dexie liveQuery; documents where a future networked (WebSocket/CRDT) backend plugs in - Dashboard + routes get a Player View entry - Robustness: encountersRepo.mutate() does transactional read-modify-write so rapid combat mutations can't overwrite each other (old C19 race); tracker uses it New player-view e2e. Networked multiplayer backend intentionally deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
305 lines
9.6 KiB
TypeScript
305 lines
9.6 KiB
TypeScript
import { db } from './db';
|
|
import { newId } from '@/lib/ids';
|
|
import {
|
|
campaignSchema,
|
|
characterSchema,
|
|
encounterSchema,
|
|
diceRollSchema,
|
|
noteSchema,
|
|
npcSchema,
|
|
questSchema,
|
|
calendarSchema,
|
|
battleMapSchema,
|
|
type Campaign,
|
|
type CampaignDraft,
|
|
type Character,
|
|
type Encounter,
|
|
type DiceRoll,
|
|
type Note,
|
|
type Npc,
|
|
type Quest,
|
|
type Calendar,
|
|
type BattleMap,
|
|
} 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, db.notes, db.npcs, db.quests, db.calendars, db.maps],
|
|
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.notes.where('campaignId').equals(id).delete();
|
|
await db.npcs.where('campaignId').equals(id).delete();
|
|
await db.quests.where('campaignId').equals(id).delete();
|
|
await db.maps.where('campaignId').equals(id).delete();
|
|
await db.calendars.delete(id);
|
|
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() });
|
|
},
|
|
|
|
/** Insert a fully-formed character (e.g. from an import). Validates on write. */
|
|
async insert(character: Character): Promise<void> {
|
|
await db.characters.add(characterSchema.parse(character));
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
/**
|
|
* Apply a transition by reading the freshest committed state inside a
|
|
* transaction, so two rapid mutations can't overwrite each other (the
|
|
* read-modify-write race the old app shipped — bug class C19).
|
|
*/
|
|
async mutate(id: string, fn: (e: Encounter) => Encounter): Promise<void> {
|
|
await db.transaction('rw', db.encounters, async () => {
|
|
const current = await db.encounters.get(id);
|
|
if (!current) return;
|
|
await db.encounters.put(encounterSchema.parse({ ...fn(current), updatedAt: now() }));
|
|
});
|
|
},
|
|
|
|
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();
|
|
},
|
|
};
|
|
|
|
// ---------------- Notes ----------------
|
|
export const notesRepo = {
|
|
listByCampaign(campaignId: string): Promise<Note[]> {
|
|
return db.notes.where('campaignId').equals(campaignId).toArray();
|
|
},
|
|
get(id: string): Promise<Note | undefined> {
|
|
return db.notes.get(id);
|
|
},
|
|
async create(campaignId: string, title: string): Promise<Note> {
|
|
const ts = now();
|
|
const note = noteSchema.parse({ id: newId(), campaignId, title, body: '', tags: [], createdAt: ts, updatedAt: ts });
|
|
await db.notes.add(note);
|
|
return note;
|
|
},
|
|
async update(id: string, patch: Partial<Omit<Note, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
|
await db.notes.update(id, { ...patch, updatedAt: now() });
|
|
},
|
|
async remove(id: string): Promise<void> {
|
|
await db.notes.delete(id);
|
|
},
|
|
};
|
|
|
|
// ---------------- NPCs ----------------
|
|
export const npcsRepo = {
|
|
listByCampaign(campaignId: string): Promise<Npc[]> {
|
|
return db.npcs.where('campaignId').equals(campaignId).toArray();
|
|
},
|
|
async create(campaignId: string, name: string): Promise<Npc> {
|
|
const ts = now();
|
|
const npc = npcSchema.parse({
|
|
id: newId(), campaignId, name, role: '', location: '', faction: '',
|
|
status: 'alive', description: '', createdAt: ts, updatedAt: ts,
|
|
});
|
|
await db.npcs.add(npc);
|
|
return npc;
|
|
},
|
|
async update(id: string, patch: Partial<Omit<Npc, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
|
await db.npcs.update(id, { ...patch, updatedAt: now() });
|
|
},
|
|
async remove(id: string): Promise<void> {
|
|
await db.npcs.delete(id);
|
|
},
|
|
};
|
|
|
|
// ---------------- Quests ----------------
|
|
export const questsRepo = {
|
|
listByCampaign(campaignId: string): Promise<Quest[]> {
|
|
return db.quests.where('campaignId').equals(campaignId).toArray();
|
|
},
|
|
async create(campaignId: string, title: string): Promise<Quest> {
|
|
const ts = now();
|
|
const quest = questSchema.parse({
|
|
id: newId(), campaignId, title, status: 'active', description: '', reward: '',
|
|
objectives: [], createdAt: ts, updatedAt: ts,
|
|
});
|
|
await db.quests.add(quest);
|
|
return quest;
|
|
},
|
|
async update(id: string, patch: Partial<Omit<Quest, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
|
await db.quests.update(id, { ...patch, updatedAt: now() });
|
|
},
|
|
async remove(id: string): Promise<void> {
|
|
await db.quests.delete(id);
|
|
},
|
|
};
|
|
|
|
// ---------------- Calendar (one per campaign) ----------------
|
|
export const calendarRepo = {
|
|
/** Read-only — safe to call inside a Dexie liveQuery. Missing = undefined. */
|
|
get(campaignId: string): Promise<Calendar | undefined> {
|
|
return db.calendars.get(campaignId);
|
|
},
|
|
async save(calendar: Calendar): Promise<void> {
|
|
await db.calendars.put(calendarSchema.parse(calendar));
|
|
},
|
|
};
|
|
|
|
// ---------------- Battle maps ----------------
|
|
export const mapsRepo = {
|
|
listByCampaign(campaignId: string): Promise<BattleMap[]> {
|
|
return db.maps.where('campaignId').equals(campaignId).toArray();
|
|
},
|
|
async create(campaignId: string, name: string, image: string): Promise<BattleMap> {
|
|
const ts = now();
|
|
const map = battleMapSchema.parse({
|
|
id: newId(), campaignId, name, image, gridSize: 50, showGrid: true,
|
|
fogEnabled: false, revealed: [], tokens: [], createdAt: ts, updatedAt: ts,
|
|
});
|
|
await db.maps.add(map);
|
|
return map;
|
|
},
|
|
async save(map: BattleMap): Promise<void> {
|
|
await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() }));
|
|
},
|
|
async remove(id: string): Promise<void> {
|
|
await db.maps.delete(id);
|
|
},
|
|
};
|