f61fabadb5
- src/lib/map/vision.ts (pure, tested): blockingSegments (walls + closed doors), segment intersection, computeVisibleCells (raycast viewer→cell-centre, sight radius). - BattleMap += dynamicVision + sightRadiusFeet (Dexie v12, backfilled). - MapEditor: new "Walls" tool (draw wall polylines; click a door to open/close), a "Vision" toggle (auto-enables fog and reveals from the party), sight-radius field, and "Reveal from party". Moving a PC token accumulates revealed cells via LoS when Vision is on. Doors/walls render for the GM (amber=closed, green=open). - Networked players need no new protocol: revealed cells already travel in the snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
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';
|
|
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
|
|
import { characterDefaults } from '@/lib/schemas/character';
|
|
|
|
/**
|
|
* 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'>;
|
|
notes!: EntityTable<Note, 'id'>;
|
|
npcs!: EntityTable<Npc, 'id'>;
|
|
quests!: EntityTable<Quest, 'id'>;
|
|
calendars!: EntityTable<Calendar, 'campaignId'>;
|
|
maps!: EntityTable<BattleMap, 'id'>;
|
|
homebrew!: EntityTable<Homebrew, 'id'>;
|
|
|
|
constructor() {
|
|
super('ttrpg-manager');
|
|
this.version(1).stores({
|
|
campaigns: 'id, updatedAt',
|
|
characters: 'id, campaignId, kind',
|
|
encounters: 'id, campaignId, status',
|
|
diceRolls: 'id, campaignId, createdAt',
|
|
});
|
|
|
|
// v2 — Phase 1 character depth: backfill new fields on existing characters.
|
|
this.version(2).stores({}).upgrade(async (tx) => {
|
|
await tx
|
|
.table('characters')
|
|
.toCollection()
|
|
.modify((c: Record<string, unknown>) => {
|
|
for (const [key, value] of Object.entries(characterDefaults())) {
|
|
if (c[key] === undefined) c[key] = value;
|
|
}
|
|
});
|
|
});
|
|
|
|
// v3 — Phase 3 combat depth: backfill the encounter event log.
|
|
this.version(3).stores({}).upgrade(async (tx) => {
|
|
await tx
|
|
.table('encounters')
|
|
.toCollection()
|
|
.modify((e: Record<string, unknown>) => {
|
|
if (e.log === undefined) e.log = [];
|
|
});
|
|
});
|
|
|
|
// v4 — Phase 5 worldbuilding: notes, NPCs, quests, calendar.
|
|
this.version(4).stores({
|
|
notes: 'id, campaignId',
|
|
npcs: 'id, campaignId',
|
|
quests: 'id, campaignId, status',
|
|
calendars: 'campaignId',
|
|
});
|
|
|
|
// v5 — Phase 7 maps/VTT.
|
|
this.version(5).stores({ maps: 'id, campaignId' });
|
|
|
|
// v6 — Phase 9 homebrew content.
|
|
this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' });
|
|
|
|
// v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot +
|
|
// outcome. Both optional, so existing rows stay valid; no backfill needed.
|
|
this.version(7).stores({});
|
|
|
|
// v8 — Phase 17 VTT overhaul: maps gain drawings/gridUnit/gridType/fogVersion;
|
|
// tokens gain size/kind/conditions/gmOnly. All defaulted, so backfill is
|
|
// belt-and-suspenders (repo re-parses on save too).
|
|
this.version(8).stores({}).upgrade(async (tx) => {
|
|
await tx
|
|
.table('maps')
|
|
.toCollection()
|
|
.modify((m: Record<string, unknown>) => {
|
|
if (m.drawings === undefined) m.drawings = [];
|
|
if (m.gridUnit === undefined) m.gridUnit = { feet: 5 };
|
|
if (m.gridType === undefined) m.gridType = 'square';
|
|
if (m.fogVersion === undefined) m.fogVersion = 1;
|
|
const tokens = Array.isArray(m.tokens) ? (m.tokens as Record<string, unknown>[]) : [];
|
|
for (const t of tokens) {
|
|
if (t.size === undefined) t.size = 1;
|
|
if (t.kind === undefined) t.kind = 'npc';
|
|
if (t.conditions === undefined) t.conditions = [];
|
|
if (t.gmOnly === undefined) t.gmOnly = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter
|
|
// combatant). Optional field — no backfill needed; existing rows parse fine.
|
|
this.version(9).stores({});
|
|
|
|
// v10 — Phase 2: characters gain `portrait`, map tokens gain `image`
|
|
// (both optional data URLs). No backfill needed.
|
|
this.version(10).stores({});
|
|
|
|
// v11 — Phase 3: maps gain walls/doors/lights (UVTT import + dynamic vision).
|
|
// Backfill empty arrays so code can read them on pre-existing maps.
|
|
this.version(11).stores({}).upgrade(async (tx) => {
|
|
await tx.table('maps').toCollection().modify((m: Record<string, unknown>) => {
|
|
if (m.walls === undefined) m.walls = [];
|
|
if (m.doors === undefined) m.doors = [];
|
|
if (m.lights === undefined) m.lights = [];
|
|
});
|
|
});
|
|
|
|
// v12 — Phase 4: maps gain dynamicVision + sightRadiusFeet.
|
|
this.version(12).stores({}).upgrade(async (tx) => {
|
|
await tx.table('maps').toCollection().modify((m: Record<string, unknown>) => {
|
|
if (m.dynamicVision === undefined) m.dynamicVision = false;
|
|
if (m.sightRadiusFeet === undefined) m.sightRadiusFeet = 60;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export const db = new TtrpgDatabase();
|