Phase 8: player view (local/projector) + sync seam
- 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>
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
indexedDB.deleteDatabase('ttrpg-manager');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player view shows the party and hides enemy HP numbers', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Show');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
// A player character
|
||||||
|
await page.getByRole('link', { name: 'Characters' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ New character' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Hero');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
// An active encounter with the hero + a monster
|
||||||
|
await page.getByRole('link', { name: 'Combat' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ New encounter' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Skirmish');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByLabel('Name').fill('Bandit');
|
||||||
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
|
const addChar = page.locator('select').filter({ has: page.locator('option', { hasText: 'Choose…' }) });
|
||||||
|
await addChar.selectOption({ label: 'Hero (Lv 1)' });
|
||||||
|
await page.getByRole('button', { name: 'Start combat' }).click();
|
||||||
|
|
||||||
|
// Player view
|
||||||
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Player View' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Show' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Hero').first()).toBeVisible();
|
||||||
|
// Enemy shows a status word, not exact HP
|
||||||
|
await expect(page.getByText('Bandit')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Healthy|Bloodied|Down/)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -36,7 +36,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
const mutate = (fn: (e: Encounter) => Encounter) => {
|
const mutate = (fn: (e: Encounter) => Encounter) => {
|
||||||
undoStack.current.push(encounter);
|
undoStack.current.push(encounter);
|
||||||
if (undoStack.current.length > 50) undoStack.current.shift();
|
if (undoStack.current.length > 50) undoStack.current.shift();
|
||||||
void encountersRepo.save(fn(encounter));
|
// Transactional read-modify-write so rapid mutations can't clobber each other.
|
||||||
|
void encountersRepo.mutate(encounter.id, fn);
|
||||||
};
|
};
|
||||||
const undo = () => {
|
const undo = () => {
|
||||||
const prev = undoStack.current.pop();
|
const prev = undoStack.current.pop();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Campaign, Combatant } from '@/lib/schemas';
|
||||||
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import { localSync } from '@/lib/sync';
|
||||||
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
|
import { useCalendar } from '@/features/world/hooks';
|
||||||
|
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
export function PlayerViewPage() {
|
||||||
|
return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
|
const sys = getSystem(campaign.system);
|
||||||
|
|
||||||
|
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||||
|
const encounter =
|
||||||
|
encounters.find((e) => e.id === activeEncounterId && e.status === 'active') ??
|
||||||
|
encounters.find((e) => e.status === 'active');
|
||||||
|
|
||||||
|
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="mb-6 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-accent">{campaign.name}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '}
|
||||||
|
<span className="uppercase">{localSync.status}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Party */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
|
||||||
|
{pcs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No player characters.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pcs.map((c) => {
|
||||||
|
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
|
||||||
|
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
||||||
|
{c.conditions.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{c.conditions.map((cond, i) => (
|
||||||
|
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Initiative */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Initiative {encounter ? `· Round ${encounter.round}` : ''}
|
||||||
|
</h2>
|
||||||
|
{!encounter ? (
|
||||||
|
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{encounter.combatants.map((c, idx) => {
|
||||||
|
const isCurrent = idx === encounter.turnIndex;
|
||||||
|
const isPC = c.kind === 'pc';
|
||||||
|
const status = enemyStatus(c);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md border px-3 py-2',
|
||||||
|
isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
|
||||||
|
<span className="flex-1 text-ink">{c.name}{isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
||||||
|
{isPC ? (
|
||||||
|
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn('text-sm font-medium', status.cls)}>{status.label}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const LINKS = [
|
|||||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||||
|
{ to: '/play', label: 'Player View', icon: '📺' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function Dashboard({ campaign }: { campaign: Campaign }) {
|
function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||||
|
|||||||
@@ -164,6 +164,19 @@ export const encountersRepo = {
|
|||||||
await db.encounters.put(validated);
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
await db.encounters.delete(id);
|
await db.encounters.delete(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Sync seam.
|
||||||
|
*
|
||||||
|
* Today the app is local-first: all data lives in Dexie and the "player view" is
|
||||||
|
* driven on the same device by liveQuery, so there is nothing to sync. This
|
||||||
|
* interface is the seam a future networked adapter (e.g. a WebSocket/CRDT
|
||||||
|
* service) implements to mirror repository writes to other devices — without the
|
||||||
|
* feature code needing to change.
|
||||||
|
*/
|
||||||
|
export type SyncStatus = 'local' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
|
export interface SyncAdapter {
|
||||||
|
readonly status: SyncStatus;
|
||||||
|
/** begin syncing this campaign (no-op locally) */
|
||||||
|
start(campaignId: string): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default adapter: single-device, backed entirely by Dexie liveQuery. */
|
||||||
|
export const localSync: SyncAdapter = {
|
||||||
|
status: 'local',
|
||||||
|
async start() {
|
||||||
|
/* nothing to do — same-device sharing is automatic via liveQuery */
|
||||||
|
},
|
||||||
|
stop() {},
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { NpcsPage } from '@/features/world/NpcsPage';
|
|||||||
import { QuestsPage } from '@/features/world/QuestsPage';
|
import { QuestsPage } from '@/features/world/QuestsPage';
|
||||||
import { CalendarPage } from '@/features/world/CalendarPage';
|
import { CalendarPage } from '@/features/world/CalendarPage';
|
||||||
import { MapsPage } from '@/features/world/MapsPage';
|
import { MapsPage } from '@/features/world/MapsPage';
|
||||||
|
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({ component: RootLayout });
|
const rootRoute = createRootRoute({ component: RootLayout });
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs',
|
|||||||
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
||||||
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
||||||
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
||||||
|
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -45,6 +47,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
questsRoute,
|
questsRoute,
|
||||||
calendarRoute,
|
calendarRoute,
|
||||||
mapsRoute,
|
mapsRoute,
|
||||||
|
playRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.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/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/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/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/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.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/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.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/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.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/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.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/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/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/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/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.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/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.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/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.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/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user