diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts
index ed40d12..76abb15 100644
--- a/e2e/maps.spec.ts
+++ b/e2e/maps.spec.ts
@@ -9,24 +9,30 @@ test.beforeEach(async ({ page }) => {
await page.reload();
});
-test('maps: upload, place a token, toggle fog', async ({ page }) => {
+test('maps: upload, place token, reveal all, show to players', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('VTT');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
- // Upload an image into the hidden file input
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
- // Editor loads (Move tool button present)
- await expect(page.getByRole('button', { name: 'Move' })).toBeVisible();
+ // Editor loads (Move tool present)
+ await expect(page.getByRole('button', { name: 'move', exact: true })).toBeVisible();
// Add a token
await page.getByRole('button', { name: '+ Token' }).click();
- await expect(page.getByTitle(/double-click to remove/)).toBeVisible();
+ await expect(page.getByTestId('map-token')).toHaveCount(1);
- // Toggle fog on (no crash)
- await page.getByText('Fog', { exact: true }).locator('input').check();
- await expect(page.getByRole('button', { name: 'Reveal all' })).toBeVisible();
+ // Reveal tool exposes the contextual fog controls; reveal all
+ await page.getByRole('button', { name: 'reveal', exact: true }).click();
+ await page.getByText('Fog', { exact: true }).locator('input[type=checkbox]').check();
+ await page.getByRole('button', { name: 'Reveal all' }).click();
+
+ // Show to players, then the player view renders the battle map
+ await page.getByRole('button', { name: 'Show to players' }).click();
+ await page.getByRole('link', { name: 'Open player view ↗' }).click();
+ await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
+ await expect(page.getByTestId('map-token')).toHaveCount(1);
});
diff --git a/src/features/player/PlayerMapView.tsx b/src/features/player/PlayerMapView.tsx
new file mode 100644
index 0000000..5ab971d
--- /dev/null
+++ b/src/features/player/PlayerMapView.tsx
@@ -0,0 +1,18 @@
+import type { PlayerMap } from '@/lib/map';
+import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
+
+/** Read-only render of a player-facing map projection + its image. */
+export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?: string; maxWidth?: number }) {
+ const tokens: CanvasToken[] = map.tokens.map((t) => ({
+ id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
+ size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
+ }));
+ return (
+
+ );
+}
diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx
index 047be8c..64228ac 100644
--- a/src/features/player/PlayerViewPage.tsx
+++ b/src/features/player/PlayerViewPage.tsx
@@ -1,31 +1,29 @@
-import type { Campaign, Combatant } from '@/lib/schemas';
+import type { Campaign } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { localSync } from '@/lib/sync';
+import { enemyStatus } from '@/lib/combat/playerProjection';
+import { toPlayerProjection } from '@/lib/map';
import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
-import { useCalendar } from '@/features/world/hooks';
+import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
+import { PlayerMapView } from './PlayerMapView';
export function PlayerViewPage() {
return {(c) => } ;
}
-/** Hide exact enemy HP from players — show a status band instead. */
-function enemyStatus(c: Combatant): { label: string; cls: string } {
- if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
- const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
- if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
- return { label: 'Healthy', cls: 'text-success' };
-}
-
function PlayerView({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const calendar = useCalendar(campaign.id);
+ const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
+ const activeMapId = useUiStore((s) => s.activeMapId);
+ const activeMap = maps.find((mp) => mp.id === activeMapId) ?? null;
const sys = getSystem(campaign.system);
const pcs = characters.filter((c) => c.kind === 'pc');
@@ -48,6 +46,13 @@ function PlayerView({ campaign }: { campaign: Campaign }) {
⛶ Fullscreen
+ {activeMap && (
+
+ )}
+
{/* Party */}
diff --git a/src/features/world/MapsPage.tsx b/src/features/world/MapsPage.tsx
index cc41e03..7c10d5a 100644
--- a/src/features/world/MapsPage.tsx
+++ b/src/features/world/MapsPage.tsx
@@ -1,17 +1,15 @@
-import { useEffect, useRef, useState } from 'react';
-import type { BattleMap, Campaign, MapToken } from '@/lib/schemas';
+import { useRef, useState } from 'react';
+import { Link } from '@tanstack/react-router';
+import type { Campaign } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories';
-import { newId } from '@/lib/ids';
-import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
+import { useUiStore } from '@/stores/uiStore';
import { useMaps } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
-import { Input } from '@/components/ui/Input';
-import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
+import { MapEditor } from './map/MapEditor';
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
-const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726'];
export function MapsPage() {
return {(c) => } ;
@@ -22,6 +20,8 @@ function Maps({ campaign }: { campaign: Campaign }) {
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
const fileRef = useRef(null);
+ const activeMapId = useUiStore((s) => s.activeMapId);
+ const setActiveMap = useUiStore((s) => s.setActiveMap);
const selected = maps.find((m) => m.id === selectedId) ?? null;
const upload = (file: File) => {
@@ -44,210 +44,39 @@ function Maps({ campaign }: { campaign: Campaign }) {
subtitle={campaign.name}
actions={ fileRef.current?.click()}>+ Upload map }
/>
- { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }}
- />
+ { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} />
{error && {error}
}
{maps.length === 0 ? (
- fileRef.current?.click()}>+ Upload map} />
+ fileRef.current?.click()}>+ Upload map} />
) : (
-
+
- {maps.map((m) => (
-
- setSelectedId(m.id)}>{m.name}
- { await mapsRepo.remove(m.id); if (selectedId === m.id) setSelectedId(null); }} aria-label={`Delete ${m.name}`}>✕
+ {maps.map((mp) => (
+
+ setSelectedId(mp.id)}>
+ {mp.name}{activeMapId === mp.id ? ' 📺' : ''}
+
+ { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}>✕
))}
- {selected ?
:
Select a map.
}
+ {selected ? (
+
+
+ setActiveMap(activeMapId === selected.id ? null : selected.id)}>
+ {activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
+
+ Open player view ↗
+
+
+
+ ) : (
+
Select a map.
+ )}
)}
);
}
-
-type Tool = 'move' | 'reveal' | 'hide';
-
-function MapEditor({ map }: { map: BattleMap }) {
- const [m, setM] = useState
(map);
- const [tool, setTool] = useState('move');
- const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
- const fogRef = useRef(null);
- const painting = useRef(false);
-
- const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
- const update = (patch: Partial) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
-
- // Load natural image size.
- useEffect(() => {
- if (!m.image) return;
- const img = new Image();
- img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
- img.src = m.image;
- }, [m.image]);
-
- const displayW = natural ? Math.min(natural.w, 880) : 0;
- const scale = natural ? displayW / natural.w : 1;
- const displayH = natural ? natural.h * scale : 0;
- const cellPx = m.gridSize * scale;
- const cols = natural ? Math.ceil(natural.w / m.gridSize) : 0;
- const rows = natural ? Math.ceil(natural.h / m.gridSize) : 0;
-
- // Redraw fog whenever it changes.
- useEffect(() => {
- const canvas = fogRef.current;
- if (!canvas || !natural) return;
- canvas.width = displayW;
- canvas.height = displayH;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- ctx.clearRect(0, 0, displayW, displayH);
- if (!m.fogEnabled) return;
- const revealed = new Set(m.revealed);
- ctx.fillStyle = 'rgba(0,0,0,0.6)';
- for (let c = 0; c < cols; c++) {
- for (let r = 0; r < rows; r++) {
- if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx, cellPx);
- }
- }
- }, [m.revealed, m.fogEnabled, displayW, displayH, cellPx, cols, rows, natural]);
-
- // Stop painting on global pointer up.
- useEffect(() => {
- const stop = () => { painting.current = false; };
- window.addEventListener('pointerup', stop);
- return () => window.removeEventListener('pointerup', stop);
- }, []);
-
- const cellAt = (e: React.PointerEvent) => {
- const rect = e.currentTarget.getBoundingClientRect();
- return { col: Math.floor((e.clientX - rect.left) / cellPx), row: Math.floor((e.clientY - rect.top) / cellPx) };
- };
- const paint = (e: React.PointerEvent) => {
- if (tool === 'move') return;
- const { col, row } = cellAt(e);
- if (col < 0 || row < 0 || col >= cols || row >= rows) return;
- const key = `${col},${row}`;
- const set = new Set(m.revealed);
- if (tool === 'reveal') set.add(key); else set.delete(key);
- update({ revealed: [...set] });
- };
-
- const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0 }] });
- const patchToken = (id: string, p: Partial) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) });
-
- return (
-
- {/* Toolbar */}
-
-
update({ name: e.target.value })} aria-label="Map name" />
-
- {(['move', 'reveal', 'hide'] as Tool[]).map((t) => (
- setTool(t)} aria-pressed={tool === t}>
- {t === 'move' ? 'Move' : t === 'reveal' ? 'Reveal' : 'Hide'}
-
- ))}
-
-
update({ showGrid: e.target.checked })} /> Grid
-
Cell update({ gridSize })} aria-label="Grid cell size" />
-
update({ fogEnabled: e.target.checked })} /> Fog
-
update({ revealed: [] })}>Hide all
-
{ const all: string[] = []; for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) all.push(`${c},${r}`); update({ revealed: all }); }}>Reveal all
-
+ Token
-
-
- {!natural ? (
-
Loading map…
- ) : (
-
-
-
- {m.showGrid && (
-
- )}
- {/* Fog + paint surface (also catches clicks in reveal/hide mode) */}
-
{ painting.current = true; paint(e); }}
- onPointerMove={(e) => { if (painting.current) paint(e); }}
- />
- {/* Tokens */}
- {m.tokens.map((t) => (
- patchToken(t.id, { col, row })}
- onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })}
- />
- ))}
-
-
- )}
-
Tip: use Reveal /Hide to paint fog; Move to drag tokens.
-
- );
-}
-
-function TokenChip({ token, cellPx, draggable, cols, rows, onMove, onRemove }: {
- token: MapToken; cellPx: number; draggable: boolean; cols: number; rows: number;
- onMove: (col: number, row: number) => void; onRemove: () => void;
-}) {
- const ref = useRef(null);
- const drag = useRef<{ startX: number; startY: number; col: number; row: number } | null>(null);
- const [drift, setDrift] = useState<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
-
- const onDown = (e: React.PointerEvent) => {
- if (!draggable) return;
- e.stopPropagation();
- ref.current?.setPointerCapture(e.pointerId);
- drag.current = { startX: e.clientX, startY: e.clientY, col: token.col, row: token.row };
- };
- const onMovePtr = (e: React.PointerEvent) => {
- if (!drag.current) return;
- setDrift({ dx: e.clientX - drag.current.startX, dy: e.clientY - drag.current.startY });
- };
- const onUp = (e: React.PointerEvent) => {
- if (!drag.current) return;
- ref.current?.releasePointerCapture(e.pointerId);
- const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
- const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
- drag.current = null;
- setDrift({ dx: 0, dy: 0 });
- onMove(col, row);
- };
-
- return (
-
- {token.label}
-
- );
-}
diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx
new file mode 100644
index 0000000..104b7d8
--- /dev/null
+++ b/src/features/world/map/MapCanvas.tsx
@@ -0,0 +1,260 @@
+import { useEffect, useRef, useState } from 'react';
+import type { Point } from '@/lib/map';
+import { cn } from '@/lib/cn';
+
+/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
+export interface CanvasDrawing {
+ id: string;
+ kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
+ points: Point[];
+ color: string;
+ width: number;
+ text?: string | undefined;
+}
+
+/** Minimal token shape both the editor (MapToken) and player view (PlayerToken) satisfy. */
+export interface CanvasToken {
+ id: string;
+ label: string;
+ color: string;
+ col: number;
+ row: number;
+ size: number;
+ kind: 'pc' | 'npc' | 'monster' | 'object';
+ hp?: { current: number; max: number; temp: number } | undefined;
+ conditions: { name: string; value?: number | undefined }[];
+}
+
+export interface CanvasView {
+ image?: string | undefined;
+ gridSize: number;
+ showGrid: boolean;
+ fogEnabled: boolean;
+ revealed: string[];
+ tokens: CanvasToken[];
+ drawings: CanvasDrawing[];
+}
+
+export interface Overlay {
+ cells?: string[];
+ line?: { a: Point; b: Point } | undefined;
+ label?: { at: Point; text: string } | undefined;
+ draft?: CanvasDrawing | undefined;
+}
+
+interface Props {
+ view: CanvasView;
+ maxWidth?: number;
+ readOnly?: boolean;
+ /** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
+ playerFog?: boolean;
+ overlay?: Overlay;
+ onPointer?: (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => void;
+ onTokenMove?: (id: string, col: number, row: number) => void;
+ onTokenClick?: (id: string) => void;
+ tokensDraggable?: boolean;
+ onReady?: (info: { cols: number; rows: number }) => void;
+}
+
+/** Shared map renderer: image + grid + fog + drawings + AoE/measure overlay + tokens. */
+export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
+ const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
+ const fogRef = useRef(null);
+ const drawRef = useRef(null);
+ const overlayRef = useRef(null);
+
+ useEffect(() => {
+ if (!view.image) { setNatural(null); return; }
+ const img = new Image();
+ img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
+ img.src = view.image;
+ }, [view.image]);
+
+ const displayW = natural ? Math.min(natural.w, maxWidth) : 0;
+ const scale = natural ? displayW / natural.w : 1;
+ const displayH = natural ? natural.h * scale : 0;
+ const cellPx = view.gridSize * scale;
+ const cols = natural ? Math.ceil(natural.w / view.gridSize) : 0;
+ const rows = natural ? Math.ceil(natural.h / view.gridSize) : 0;
+
+ useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
+
+ // Fog
+ useEffect(() => {
+ const cv = fogRef.current;
+ if (!cv || !natural) return;
+ cv.width = displayW; cv.height = displayH;
+ const ctx = cv.getContext('2d'); if (!ctx) return;
+ ctx.clearRect(0, 0, displayW, displayH);
+ if (!view.fogEnabled) return;
+ const revealed = new Set(view.revealed);
+ ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
+ for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
+ if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx + 0.5, cellPx + 0.5);
+ }
+ }, [view.revealed, view.fogEnabled, playerFog, displayW, displayH, cellPx, cols, rows, natural]);
+
+ // Drawings
+ useEffect(() => {
+ const cv = drawRef.current;
+ if (!cv || !natural) return;
+ cv.width = displayW; cv.height = displayH;
+ const ctx = cv.getContext('2d'); if (!ctx) return;
+ ctx.clearRect(0, 0, displayW, displayH);
+ for (const d of view.drawings) drawShape(ctx, d, scale);
+ }, [view.drawings, displayW, displayH, scale, natural]);
+
+ // Overlay (AoE/measure/draft)
+ useEffect(() => {
+ const cv = overlayRef.current;
+ if (!cv || !natural) return;
+ cv.width = displayW; cv.height = displayH;
+ const ctx = cv.getContext('2d'); if (!ctx) return;
+ ctx.clearRect(0, 0, displayW, displayH);
+ if (overlay?.cells?.length) {
+ ctx.fillStyle = 'rgba(212,175,55,0.35)';
+ for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * cellPx, r! * cellPx, cellPx, cellPx); }
+ }
+ if (overlay?.draft) drawShape(ctx, overlay.draft, scale);
+ if (overlay?.line) {
+ ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.moveTo(overlay.line.a.x * scale, overlay.line.a.y * scale); ctx.lineTo(overlay.line.b.x * scale, overlay.line.b.y * scale); ctx.stroke();
+ }
+ if (overlay?.label) {
+ ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
+ const x = overlay.label.at.x * scale + 6, y = overlay.label.at.y * scale - 6;
+ ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
+ }
+ }, [overlay, displayW, displayH, cellPx, scale, natural]);
+
+ const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / scale, y = (e.clientY - rect.top) / scale;
+ return { point: { x, y }, col: Math.floor(x / view.gridSize), row: Math.floor(y / view.gridSize) };
+ };
+
+ if (!view.image) return No image.
;
+ if (!natural) return Loading map…
;
+
+ const interactive = !readOnly && !!onPointer;
+ return (
+
+
+
+ {view.showGrid && (
+
+ )}
+
+
+
+ {interactive && (
+
{ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onPointer!('down', toPoint(e)); }}
+ onPointerMove={(e) => { if (e.buttons) onPointer!('move', toPoint(e)); }}
+ onPointerUp={(e) => onPointer!('up', toPoint(e))}
+ />
+ )}
+ {view.tokens.map((t) => (
+
+ ))}
+
+
+ );
+}
+
+function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: number): void {
+ ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width * scale; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
+ const p = d.points.map((pt) => ({ x: pt.x * scale, y: pt.y * scale }));
+ if (d.kind === 'text' && d.text && p[0]) {
+ ctx.font = `${Math.max(12, d.width * 5 * scale)}px sans-serif`;
+ ctx.fillText(d.text, p[0].x, p[0].y);
+ return;
+ }
+ if (p.length < 2) return;
+ const a = p[0]!, b = p[p.length - 1]!;
+ ctx.beginPath();
+ if (d.kind === 'rect') {
+ ctx.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
+ } else if (d.kind === 'circle') {
+ ctx.arc(a.x, a.y, Math.hypot(b.x - a.x, b.y - a.y), 0, Math.PI * 2);
+ } else {
+ ctx.moveTo(p[0]!.x, p[0]!.y);
+ for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
+ if (d.kind === 'arrow') {
+ const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width * scale;
+ ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4));
+ ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4));
+ }
+ }
+ ctx.stroke();
+}
+
+function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
+ token: CanvasToken; cellPx: number; cols: number; rows: number;
+ draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
+}) {
+ const ref = useRef
(null);
+ const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
+ const [drift, setDrift] = useState({ dx: 0, dy: 0 });
+ const span = cellPx * token.size;
+ const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
+
+ const down = (e: React.PointerEvent) => {
+ if (draggable) { e.stopPropagation(); ref.current?.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, col: token.col, row: token.row, moved: false }; }
+ };
+ const move = (e: React.PointerEvent) => {
+ if (!drag.current) return;
+ const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.current.moved = true;
+ setDrift({ dx, dy });
+ };
+ const up = (e: React.PointerEvent) => {
+ if (!drag.current) return;
+ ref.current?.releasePointerCapture(e.pointerId);
+ const moved = drag.current.moved;
+ if (moved && onMove) {
+ const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
+ const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
+ onMove(token.id, col, row);
+ } else if (!moved && onClick) onClick(token.id);
+ drag.current = null; setDrift({ dx: 0, dy: 0 });
+ };
+
+ return (
+
+ {frac !== null && (
+
+
+ 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'}
+ strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
+
+ )}
+ {token.label}
+ {token.conditions.length > 0 && (
+ c.name).join(', ')}>
+ {token.conditions.length}
+
+ )}
+
+ );
+}
diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx
new file mode 100644
index 0000000..6c70252
--- /dev/null
+++ b/src/features/world/map/MapEditor.tsx
@@ -0,0 +1,225 @@
+import { useMemo, useRef, useState } from 'react';
+import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
+import { mapsRepo } from '@/lib/db/repositories';
+import { newId } from '@/lib/ids';
+import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
+import {
+ brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
+ gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
+ type Point,
+} from '@/lib/map';
+import { useCharacters } from '@/features/characters/hooks';
+import { Button } from '@/components/ui/Button';
+import { Input, Select } from '@/components/ui/Input';
+import { NumberField } from '@/components/ui/NumberField';
+import { Modal } from '@/components/ui/Modal';
+import { cn } from '@/lib/cn';
+import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
+
+const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
+type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping';
+type FogShape = 'brush' | 'rect' | 'poly';
+type AoeShape = 'circle' | 'cone' | 'line' | 'square';
+type DrawKind = MapDrawing['kind'];
+
+export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
+ const [m, setM] = useState(map);
+ const characters = useCharacters(campaign.id);
+ const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
+ const update = (patch: Partial) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
+
+ const [tool, setTool] = useState('move');
+ const [fogShape, setFogShape] = useState('brush');
+ const [brush, setBrush] = useState(0);
+ const [aoeShape, setAoeShape] = useState('circle');
+ const [aoeFeet, setAoeFeet] = useState(20);
+ const [drawKind, setDrawKind] = useState('freehand');
+ const [drawColor, setDrawColor] = useState('#d4af37');
+ const [gmDraw, setGmDraw] = useState(true);
+ const [dims, setDims] = useState({ cols: 0, rows: 0 });
+ const [editToken, setEditToken] = useState(null);
+
+ // transient interaction state
+ const [overlay, setOverlay] = useState({});
+ const anchor = useRef(null);
+ const [poly, setPoly] = useState([]);
+ const draftPts = useRef([]);
+
+ const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
+
+ // Live HP from a linked character drives the ring.
+ const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
+ const linked = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
+ return {
+ id: t.id, label: t.label || (linked?.name ?? ''), color: t.color, col: t.col, row: t.row,
+ size: t.size, kind: t.kind, hp: linked?.hp ?? t.hp, conditions: t.conditions,
+ };
+ }), [m.tokens, characters]);
+
+ const commitReveal = (cells: string[], reveal: boolean) =>
+ update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
+
+ const onPointer = (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => {
+ const reveal = tool === 'reveal';
+ if (tool === 'reveal' || tool === 'hide') {
+ if (fogShape === 'brush') {
+ if (phase !== 'up') commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal);
+ } else if (fogShape === 'rect') {
+ if (phase === 'down') anchor.current = p.point;
+ else if (anchor.current) {
+ const a = worldToCell(anchor.current, m.gridSize);
+ const cells = rectCells(a, { col: p.col, row: p.row }, dims.cols, dims.rows);
+ if (phase === 'move') setOverlay({ cells });
+ else { commitReveal(cells, reveal); anchor.current = null; setOverlay({}); }
+ }
+ } else if (fogShape === 'poly') {
+ if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ cells: polygonCells(next, gridSpec()) }); }
+ }
+ return;
+ }
+ if (tool === 'measure') {
+ if (phase === 'down') anchor.current = p.point;
+ else if (anchor.current) {
+ const a = worldToCell(anchor.current, m.gridSize);
+ const cells = gridDistance(a, { col: p.col, row: p.row }, distMode);
+ setOverlay({ line: { a: anchor.current, b: p.point }, label: { at: p.point, text: `${toFeet(cells, m.gridUnit.feet)} ft` } });
+ if (phase === 'up') anchor.current = null;
+ }
+ return;
+ }
+ if (tool === 'aoe') {
+ if (phase === 'down') anchor.current = p.point;
+ const origin = anchor.current ?? p.point;
+ let cells: string[] = [];
+ if (aoeShape === 'circle') cells = circleCells(p.point, aoeFeet, gridSpec());
+ else if (aoeShape === 'square') cells = squareCells(p.point, aoeFeet, gridSpec());
+ else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, 53, gridSpec());
+ else cells = lineCells(origin, p.point, 5, gridSpec());
+ setOverlay({ cells });
+ if (phase === 'up' && aoeShape !== 'circle' && aoeShape !== 'square') anchor.current = null;
+ return;
+ }
+ if (tool === 'draw') {
+ if (drawKind === 'text') {
+ if (phase === 'down') {
+ const text = window.prompt('Label text:')?.trim();
+ if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
+ }
+ return;
+ }
+ if (phase === 'down') { draftPts.current = [p.point]; }
+ else if (phase === 'move') {
+ draftPts.current = drawKind === 'freehand' ? [...draftPts.current, p.point] : [draftPts.current[0]!, p.point];
+ setOverlay({ draft: { id: 'draft', kind: drawKind, points: draftPts.current, color: drawColor, width: 3 } });
+ } else {
+ if (draftPts.current.length >= 2) update({ drawings: [...m.drawings, { id: newId(), kind: drawKind, points: draftPts.current, color: drawColor, width: 3, gmOnly: gmDraw }] });
+ draftPts.current = []; setOverlay({});
+ }
+ return;
+ }
+ if (tool === 'ping' && phase === 'down') {
+ const at = p.point;
+ setOverlay({ cells: [`${p.col},${p.row}`] });
+ window.setTimeout(() => setOverlay((o) => (o.cells?.[0] === `${p.col},${p.row}` ? {} : o)), 1500);
+ void at;
+ }
+ };
+
+ const gridSpec = () => ({ gridSize: m.gridSize, feetPerCell: m.gridUnit.feet, cols: dims.cols, rows: dims.rows });
+
+ const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
+
+ const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
+ const patchToken = (id: string, patch: Partial) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
+ const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
+ const token = m.tokens.find((t) => t.id === editToken) ?? null;
+
+ return (
+
+
+ update({ name: e.target.value })} aria-label="Map name" />
+ {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
+ { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}
+ ))}
+
+
+ {/* Contextual sub-toolbar */}
+
+ update({ showGrid: e.target.checked })} /> Grid
+ update({ fogEnabled: e.target.checked })} /> Fog
+ Cell px update({ gridSize })} aria-label="Grid cell size" />
+ Feet/cell update({ gridUnit: { feet } })} aria-label="Feet per cell" />
+
+ {(tool === 'reveal' || tool === 'hide') && (
+ <>
+
+ {(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
+ { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}
+ ))}
+ {fogShape === 'brush' && Size }
+ {fogShape === 'poly' && Finish polygon ({poly.length}) }
+ update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all
+ update({ revealed: hideAll() })}>Hide all
+ >
+ )}
+ {tool === 'aoe' && (
+ <>
+
+ {(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
+ { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}
+ ))}
+ Feet
+ setOverlay({})}>Clear
+ >
+ )}
+ {tool === 'draw' && (
+ <>
+
+ setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
+ {(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => {k} )}
+
+ setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
+ setGmDraw(e.target.checked)} /> GM only
+ update({ drawings: m.drawings.slice(0, -1) })}>Undo draw
+ update({ drawings: [] })}>Clear drawings
+ >
+ )}
+
+ + Token
+
+
+
patchToken(id, { col, row })}
+ onTokenClick={(id) => setEditToken(id)}
+ onReady={setDims}
+ />
+
+ Click a token to edit it. Use Reveal/Hide (brush, rectangle, polygon) for fog,
+ Measure /AoE for ranges, Draw for annotations, then “Open player view” to show players.
+
+
+ {token && (
+ setEditToken(null)} title="Token"
+ footer={<> setEditToken(null)}>Done { removeToken(token.id); setEditToken(null); }}>Delete >}>
+
+
+ Label patchToken(token.id, { label: e.target.value })} />
+ Size patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => {s}×{s} )}
+ Kind patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => {k} )}
+ Link character patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}>— none — {characters.map((c) => {c.name} )}
+
+
+ {TOKEN_COLORS.map((c) => patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
+
+
patchToken(token.id, { gmOnly: e.target.checked })} /> Hidden from players (GM-only)
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/combat/playerProjection.ts b/src/lib/combat/playerProjection.ts
new file mode 100644
index 0000000..063f748
--- /dev/null
+++ b/src/lib/combat/playerProjection.ts
@@ -0,0 +1,52 @@
+import type { Combatant, Encounter } from '@/lib/schemas';
+
+export interface EnemyStatus {
+ label: 'Healthy' | 'Bloodied' | 'Down';
+ cls: string;
+}
+
+/** Hide exact enemy HP from players — a status band instead. */
+export function enemyStatus(c: Pick): EnemyStatus {
+ if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
+ const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
+ if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
+ return { label: 'Healthy', cls: 'text-success' };
+}
+
+export interface PlayerCombatant {
+ id: string;
+ name: string;
+ kind: Combatant['kind'];
+ initiative: number;
+ isCurrent: boolean;
+ /** exact HP for PCs; undefined for hidden enemies */
+ hp?: { current: number; max: number; temp: number };
+ status?: EnemyStatus;
+}
+
+export interface PlayerEncounter {
+ id: string;
+ name: string;
+ round: number;
+ combatants: PlayerCombatant[];
+}
+
+/** Build the player-safe view of an encounter (enemy HP masked). */
+export function toPlayerEncounter(e: Encounter): PlayerEncounter {
+ return {
+ id: e.id,
+ name: e.name,
+ round: e.round,
+ combatants: e.combatants.map((c, idx) => {
+ const isPC = c.kind === 'pc';
+ return {
+ id: c.id,
+ name: c.name,
+ kind: c.kind,
+ initiative: c.initiative,
+ isCurrent: idx === e.turnIndex,
+ ...(isPC ? { hp: c.hp } : { status: enemyStatus(c) }),
+ };
+ }),
+ };
+}
diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts
index 8785dae..97473c2 100644
--- a/src/lib/db/db.ts
+++ b/src/lib/db/db.ts
@@ -74,6 +74,28 @@ export class TtrpgDatabase extends Dexie {
// 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) => {
+ 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[]) : [];
+ 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;
+ }
+ });
+ });
}
}
diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts
index c129fc0..f7e9a40 100644
--- a/src/lib/db/repositories.ts
+++ b/src/lib/db/repositories.ts
@@ -299,9 +299,20 @@ export const mapsRepo = {
await db.maps.add(map);
return map;
},
+ get(id: string): Promise {
+ return db.maps.get(id);
+ },
async save(map: BattleMap): Promise {
await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() }));
},
+ /** Transactional read-modify-write (mirrors encountersRepo.mutate). */
+ async mutate(id: string, fn: (m: BattleMap) => BattleMap): Promise {
+ await db.transaction('rw', db.maps, async () => {
+ const current = await db.maps.get(id);
+ if (!current) return;
+ await db.maps.put(battleMapSchema.parse({ ...fn(current), updatedAt: now() }));
+ });
+ },
async remove(id: string): Promise {
await db.maps.delete(id);
},
diff --git a/src/lib/map/distance.ts b/src/lib/map/distance.ts
new file mode 100644
index 0000000..94eb9cc
--- /dev/null
+++ b/src/lib/map/distance.ts
@@ -0,0 +1,21 @@
+import type { Cell, DistanceMode } from './types';
+
+/**
+ * Distance between two cells, in cells.
+ * - 5e: Chebyshev (diagonals cost 1).
+ * - pf2e: diagonals alternate 1,2,1,2 (the 5-10-5 rule).
+ * - euclid: straight-line, rounded.
+ */
+export function gridDistance(a: Cell, b: Cell, mode: DistanceMode = '5e'): number {
+ const dx = Math.abs(a.col - b.col);
+ const dy = Math.abs(a.row - b.row);
+ const diag = Math.min(dx, dy);
+ const straight = Math.max(dx, dy) - diag;
+ if (mode === 'euclid') return Math.round(Math.hypot(dx, dy));
+ if (mode === 'pf2e') return straight + diag + Math.floor(diag / 2);
+ return straight + diag; // 5e Chebyshev
+}
+
+export function toFeet(cells: number, feetPerCell: number): number {
+ return cells * feetPerCell;
+}
diff --git a/src/lib/map/fog.ts b/src/lib/map/fog.ts
new file mode 100644
index 0000000..b4c4b57
--- /dev/null
+++ b/src/lib/map/fog.ts
@@ -0,0 +1,22 @@
+import { cellKey } from './grid';
+
+/** Immutable fog set algebra over the `revealed` cell-key array. */
+
+export function applyReveal(revealed: string[], cells: string[]): string[] {
+ return [...new Set([...revealed, ...cells])];
+}
+
+export function applyHide(revealed: string[], cells: string[]): string[] {
+ const drop = new Set(cells);
+ return revealed.filter((k) => !drop.has(k));
+}
+
+export function revealAll(cols: number, rows: number): string[] {
+ const all: string[] = [];
+ for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) all.push(cellKey(c, r));
+ return all;
+}
+
+export function hideAll(): string[] {
+ return [];
+}
diff --git a/src/lib/map/grid.ts b/src/lib/map/grid.ts
new file mode 100644
index 0000000..02c7b65
--- /dev/null
+++ b/src/lib/map/grid.ts
@@ -0,0 +1,38 @@
+import type { Cell, Point } from './types';
+
+export function cellKey(col: number, row: number): string {
+ return `${col},${row}`;
+}
+
+export function parseCellKey(key: string): Cell {
+ const [c, r] = key.split(',');
+ return { col: Number(c), row: Number(r) };
+}
+
+/** Image-space pixel → grid cell. */
+export function worldToCell(pt: Point, gridSize: number): Cell {
+ return { col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) };
+}
+
+/** Center of a cell in image-space pixels. */
+export function cellCenter(col: number, row: number, gridSize: number): Point {
+ return { x: (col + 0.5) * gridSize, y: (row + 0.5) * gridSize };
+}
+
+export function gridDims(naturalW: number, naturalH: number, gridSize: number): { cols: number; rows: number } {
+ return { cols: Math.max(0, Math.ceil(naturalW / gridSize)), rows: Math.max(0, Math.ceil(naturalH / gridSize)) };
+}
+
+export function inBounds(col: number, row: number, cols: number, rows: number): boolean {
+ return col >= 0 && row >= 0 && col < cols && row < rows;
+}
+
+/** Cells covered by an NxN token whose top-left is (col,row). */
+export function tokenCells(token: { col: number; row: number; size: number }): string[] {
+ const out: string[] = [];
+ const n = Math.max(1, token.size);
+ for (let c = token.col; c < token.col + n; c++) {
+ for (let r = token.row; r < token.row + n; r++) out.push(cellKey(c, r));
+ }
+ return out;
+}
diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts
new file mode 100644
index 0000000..d39d791
--- /dev/null
+++ b/src/lib/map/index.ts
@@ -0,0 +1,6 @@
+export * from './types';
+export * from './grid';
+export * from './distance';
+export * from './shapes';
+export * from './fog';
+export * from './projection';
diff --git a/src/lib/map/map.test.ts b/src/lib/map/map.test.ts
new file mode 100644
index 0000000..91287e3
--- /dev/null
+++ b/src/lib/map/map.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import {
+ cellKey, parseCellKey, worldToCell, gridDims, tokenCells,
+ gridDistance, toFeet,
+ brushCells, rectCells, pointInPolygon, polygonCells, circleCells, coneCells, lineCells, squareCells,
+ applyReveal, applyHide, revealAll, hideAll,
+ type GridSpec,
+} from './index';
+
+const grid: GridSpec = { gridSize: 50, feetPerCell: 5, cols: 10, rows: 10 };
+
+describe('grid', () => {
+ it('round-trips cell keys and converts world→cell', () => {
+ expect(cellKey(2, 3)).toBe('2,3');
+ expect(parseCellKey('4,7')).toEqual({ col: 4, row: 7 });
+ expect(worldToCell({ x: 120, y: 40 }, 50)).toEqual({ col: 2, row: 0 });
+ });
+ it('computes grid dims and NxN token footprints', () => {
+ expect(gridDims(500, 300, 50)).toEqual({ cols: 10, rows: 6 });
+ expect(tokenCells({ col: 1, row: 1, size: 2 }).sort()).toEqual(['1,1', '1,2', '2,1', '2,2'].sort());
+ });
+});
+
+describe('distance', () => {
+ it('5e Chebyshev, pf2e 5-10-5, euclidean', () => {
+ const a = { col: 0, row: 0 };
+ expect(gridDistance(a, { col: 3, row: 0 }, '5e')).toBe(3);
+ expect(gridDistance(a, { col: 3, row: 3 }, '5e')).toBe(3); // diagonals free
+ expect(gridDistance(a, { col: 2, row: 2 }, 'pf2e')).toBe(3); // 1 + 2 (second diagonal costs 2)
+ expect(gridDistance(a, { col: 3, row: 4 }, 'euclid')).toBe(5);
+ expect(toFeet(3, 5)).toBe(15);
+ });
+});
+
+describe('reveal/hide tools', () => {
+ it('brush covers a square block, clamped to bounds', () => {
+ expect(brushCells(0, 0, 1, 10, 10).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
+ });
+ it('rect is inclusive', () => {
+ expect(rectCells({ col: 0, row: 0 }, { col: 1, row: 1 }, 10, 10).sort())
+ .toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
+ });
+ it('polygon point-in + rasterize', () => {
+ const square = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
+ expect(pointInPolygon({ x: 50, y: 50 }, square)).toBe(true);
+ expect(pointInPolygon({ x: 150, y: 50 }, square)).toBe(false);
+ // cells whose centers (25,25 / 75,25 / 25,75 / 75,75) fall inside
+ expect(polygonCells(square, grid).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
+ });
+});
+
+describe('AoE templates', () => {
+ it('circle covers cells within radius', () => {
+ const cells = circleCells({ x: 25, y: 25 }, 5, grid); // 5ft = 1 cell radius
+ expect(cells).toContain('0,0');
+ expect(cells).not.toContain('5,5');
+ });
+ it('square, cone, line return non-empty within bounds', () => {
+ expect(squareCells({ x: 100, y: 100 }, 15, grid).length).toBeGreaterThan(0);
+ expect(coneCells({ x: 0, y: 0 }, { x: 100, y: 0 }, 15, 53, grid)).toContain('1,0');
+ expect(lineCells({ x: 0, y: 25 }, { x: 250, y: 25 }, 5, grid)).toContain('0,0');
+ });
+});
+
+describe('fog set algebra', () => {
+ it('reveal dedupes, hide removes, all/none', () => {
+ expect(applyReveal(['0,0'], ['0,0', '1,1']).sort()).toEqual(['0,0', '1,1'].sort());
+ expect(applyHide(['0,0', '1,1'], ['0,0'])).toEqual(['1,1']);
+ expect(revealAll(2, 2).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
+ expect(hideAll()).toEqual([]);
+ });
+});
diff --git a/src/lib/map/projection.ts b/src/lib/map/projection.ts
new file mode 100644
index 0000000..4094715
--- /dev/null
+++ b/src/lib/map/projection.ts
@@ -0,0 +1,38 @@
+import type { BattleMap } from '@/lib/schemas';
+import type { PlayerDrawing, PlayerMap, PlayerToken } from './types';
+
+/**
+ * Reduce a full BattleMap to what players may see: GM-only tokens/drawings are
+ * removed; the image data URL is excluded (it travels separately / from local
+ * Dexie). Revealed cells pass through (the player renderer fogs the rest).
+ */
+export function toPlayerProjection(map: BattleMap): PlayerMap {
+ const tokens: PlayerToken[] = map.tokens
+ .filter((t) => !t.gmOnly)
+ .map((t) => ({
+ id: t.id, label: t.label, color: t.color, col: t.col, row: t.row, size: t.size, kind: t.kind,
+ ...(t.hp ? { hp: t.hp } : {}),
+ conditions: t.conditions.map((c) => ({ name: c.name, ...(c.value !== undefined ? { value: c.value } : {}) })),
+ }));
+
+ const drawings: PlayerDrawing[] = map.drawings
+ .filter((d) => !d.gmOnly)
+ .map((d) => ({
+ id: d.id, kind: d.kind, points: d.points, color: d.color, width: d.width,
+ ...(d.text !== undefined ? { text: d.text } : {}),
+ }));
+
+ return {
+ id: map.id,
+ name: map.name,
+ gridSize: map.gridSize,
+ showGrid: map.showGrid,
+ fogEnabled: map.fogEnabled,
+ revealed: map.revealed,
+ gridType: map.gridType,
+ gridUnit: map.gridUnit,
+ tokens,
+ drawings,
+ hasImage: !!map.image,
+ };
+}
diff --git a/src/lib/map/shapes.ts b/src/lib/map/shapes.ts
new file mode 100644
index 0000000..32508c6
--- /dev/null
+++ b/src/lib/map/shapes.ts
@@ -0,0 +1,103 @@
+import type { GridSpec, Point } from './types';
+import { cellCenter, cellKey, inBounds } from './grid';
+
+// ---- Reveal/hide authoring tools → cell keys ----
+
+/** Square brush of radius `r` cells centered on (col,row): a (2r+1)² block. */
+export function brushCells(col: number, row: number, r: number, cols: number, rows: number): string[] {
+ const out: string[] = [];
+ const rr = Math.max(0, Math.floor(r));
+ for (let c = col - rr; c <= col + rr; c++) {
+ for (let rw = row - rr; rw <= row + rr; rw++) {
+ if (inBounds(c, rw, cols, rows)) out.push(cellKey(c, rw));
+ }
+ }
+ return out;
+}
+
+/** Inclusive rectangle between two cells. */
+export function rectCells(a: { col: number; row: number }, b: { col: number; row: number }, cols: number, rows: number): string[] {
+ const out: string[] = [];
+ const c0 = Math.min(a.col, b.col), c1 = Math.max(a.col, b.col);
+ const r0 = Math.min(a.row, b.row), r1 = Math.max(a.row, b.row);
+ for (let c = c0; c <= c1; c++) for (let r = r0; r <= r1; r++) if (inBounds(c, r, cols, rows)) out.push(cellKey(c, r));
+ return out;
+}
+
+/** Ray-casting point-in-polygon (verts in any units consistent with `pt`). */
+export function pointInPolygon(pt: Point, verts: Point[]): boolean {
+ let inside = false;
+ for (let i = 0, j = verts.length - 1; i < verts.length; j = i++) {
+ const a = verts[i]!, b = verts[j]!;
+ const intersect = a.y > pt.y !== b.y > pt.y && pt.x < ((b.x - a.x) * (pt.y - a.y)) / (b.y - a.y) + a.x;
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+
+/** Rasterize a polygon (image-space px verts) to covered cell keys. */
+export function polygonCells(verts: Point[], grid: GridSpec): string[] {
+ if (verts.length < 3) return [];
+ const out: string[] = [];
+ for (let c = 0; c < grid.cols; c++) {
+ for (let r = 0; r < grid.rows; r++) {
+ if (pointInPolygon(cellCenter(c, r, grid.gridSize), verts)) out.push(cellKey(c, r));
+ }
+ }
+ return out;
+}
+
+// ---- AoE templates (image-space px) → covered cell keys ----
+
+function feetToPx(feet: number, grid: GridSpec): number {
+ return (feet / grid.feetPerCell) * grid.gridSize;
+}
+
+function forEachCellCenter(grid: GridSpec, fn: (c: number, r: number, center: Point) => void): void {
+ for (let c = 0; c < grid.cols; c++) for (let r = 0; r < grid.rows; r++) fn(c, r, cellCenter(c, r, grid.gridSize));
+}
+
+export function circleCells(center: Point, radiusFeet: number, grid: GridSpec): string[] {
+ const rPx = feetToPx(radiusFeet, grid);
+ const out: string[] = [];
+ forEachCellCenter(grid, (c, r, p) => { if (Math.hypot(p.x - center.x, p.y - center.y) <= rPx) out.push(cellKey(c, r)); });
+ return out;
+}
+
+export function squareCells(center: Point, sideFeet: number, grid: GridSpec): string[] {
+ const half = feetToPx(sideFeet, grid) / 2;
+ const out: string[] = [];
+ forEachCellCenter(grid, (c, r, p) => {
+ if (Math.abs(p.x - center.x) <= half && Math.abs(p.y - center.y) <= half) out.push(cellKey(c, r));
+ });
+ return out;
+}
+
+export function coneCells(origin: Point, target: Point, lengthFeet: number, angleDeg: number, grid: GridSpec): string[] {
+ const lenPx = feetToPx(lengthFeet, grid);
+ const dir = Math.atan2(target.y - origin.y, target.x - origin.x);
+ const halfAngle = (angleDeg * Math.PI) / 180 / 2;
+ const out: string[] = [];
+ forEachCellCenter(grid, (c, r, p) => {
+ const vx = p.x - origin.x, vy = p.y - origin.y;
+ const dist = Math.hypot(vx, vy);
+ if (dist === 0 || dist > lenPx) return;
+ let da = Math.abs(Math.atan2(vy, vx) - dir);
+ if (da > Math.PI) da = 2 * Math.PI - da;
+ if (da <= halfAngle) out.push(cellKey(c, r));
+ });
+ return out;
+}
+
+export function lineCells(a: Point, b: Point, widthFeet: number, grid: GridSpec): string[] {
+ const halfW = feetToPx(widthFeet, grid) / 2;
+ const out: string[] = [];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const len2 = dx * dx + dy * dy;
+ forEachCellCenter(grid, (c, r, p) => {
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
+ const px = a.x + t * dx, py = a.y + t * dy;
+ if (Math.hypot(p.x - px, p.y - py) <= halfW) out.push(cellKey(c, r));
+ });
+ return out;
+}
diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts
new file mode 100644
index 0000000..9c857d0
--- /dev/null
+++ b/src/lib/map/types.ts
@@ -0,0 +1,59 @@
+/** Shared, dependency-free map geometry + projection types (client + server). */
+
+export interface Cell {
+ col: number;
+ row: number;
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+/** Everything geometry needs about the current grid, in natural image pixels. */
+export interface GridSpec {
+ gridSize: number; // px per cell (natural image space)
+ feetPerCell: number;
+ cols: number;
+ rows: number;
+}
+
+export type DistanceMode = '5e' | 'pf2e' | 'euclid';
+
+// ---- Player-facing projection (what a player may see) ----
+
+export interface PlayerToken {
+ id: string;
+ label: string;
+ color: string;
+ col: number;
+ row: number;
+ size: number;
+ kind: 'pc' | 'npc' | 'monster' | 'object';
+ hp?: { current: number; max: number; temp: number };
+ conditions: { name: string; value?: number }[];
+}
+
+export interface PlayerDrawing {
+ id: string;
+ kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
+ points: Point[];
+ color: string;
+ width: number;
+ text?: string;
+}
+
+export interface PlayerMap {
+ id: string;
+ name: string;
+ gridSize: number;
+ showGrid: boolean;
+ fogEnabled: boolean;
+ revealed: string[];
+ gridType: 'square' | 'hex';
+ gridUnit: { feet: number };
+ tokens: PlayerToken[];
+ drawings: PlayerDrawing[];
+ /** whether an image exists (the data URL itself travels separately) */
+ hasImage: boolean;
+}
diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts
index f7acaa3..a2faaaf 100644
--- a/src/lib/schemas/world.ts
+++ b/src/lib/schemas/world.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { int, systemIdSchema } from './common';
+import { int, num, systemIdSchema, hpSchema, conditionSchema } from './common';
// ---------------- Notes / Wiki ----------------
export const noteSchema = z.object({
@@ -90,12 +90,37 @@ export const mapTokenSchema = z.object({
id: z.string(),
label: z.string().max(40).default(''),
color: z.string().max(20).default('#d4af37'),
- /** grid cell coordinates */
+ /** grid cell coordinates (top-left cell of the footprint) */
col: int,
row: int,
+ /** NxN footprint (1 = one cell) */
+ size: int.min(1).max(4).default(1),
+ kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
+ /** optional link to a Character for a live HP ring / conditions */
+ characterId: z.string().optional(),
+ /** denormalized HP snapshot (used when no live combatant drives the ring) */
+ hp: hpSchema.optional(),
+ conditions: z.array(conditionSchema).default([]),
+ /** hidden from the player-facing projection */
+ gmOnly: z.boolean().default(false),
});
export type MapToken = z.infer;
+/** A point in image-space pixels (scale-independent, network-portable). */
+export const pointSchema = z.object({ x: num, y: num });
+export type MapPoint = z.infer;
+
+export const drawingSchema = z.object({
+ id: z.string(),
+ kind: z.enum(['freehand', 'line', 'rect', 'circle', 'arrow', 'text']),
+ points: z.array(pointSchema).default([]),
+ color: z.string().max(20).default('#d4af37'),
+ width: num.min(1).max(40).default(3),
+ text: z.string().max(200).optional(),
+ gmOnly: z.boolean().default(false),
+});
+export type MapDrawing = z.infer;
+
export const battleMapSchema = z.object({
id: z.string(),
campaignId: z.string(),
@@ -108,6 +133,13 @@ export const battleMapSchema = z.object({
/** revealed cell keys "col,row"; everything else is fogged when enabled */
revealed: z.array(z.string()).default([]),
tokens: z.array(mapTokenSchema).default([]),
+ /** GM annotation layer (some entries may be gmOnly) */
+ drawings: z.array(drawingSchema).default([]),
+ /** real-world feet per grid cell, for measurement + AoE labels */
+ gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }),
+ gridType: z.enum(['square', 'hex']).default('square'),
+ /** fog model version, for future migrations */
+ fogVersion: int.default(1),
createdAt: z.string(),
updatedAt: z.string(),
});
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts
index 721cb87..11049e9 100644
--- a/src/stores/uiStore.ts
+++ b/src/stores/uiStore.ts
@@ -9,10 +9,13 @@ interface UiState {
activeCampaignId: string | null;
/** encounter currently open in the combat tracker (so the bestiary can target it) */
activeEncounterId: string | null;
+ /** map currently projected to the player view */
+ activeMapId: string | null;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void;
+ setActiveMap: (id: string | null) => void;
}
export const useUiStore = create()(
@@ -21,13 +24,15 @@ export const useUiStore = create()(
theme: 'dark',
activeCampaignId: null,
activeEncounterId: null,
+ activeMapId: null,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
- setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null }),
+ setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null }),
setActiveEncounter: (id) => set({ activeEncounterId: id }),
+ setActiveMap: (id) => set({ activeMapId: id }),
}),
{
name: 'ttrpg-ui',
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index 0f203a8..1f0bcfd 100644
--- a/tsconfig.app.tsbuildinfo
+++ b/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
\ No newline at end of file