From 744e91f703c0ac090ae51f826b94ed23e6c8640a Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 02:07:27 +0200 Subject: [PATCH] Phase 8: player view (local/projector) + sync seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- e2e/player-view.spec.ts | 42 ++++++++ src/features/combat/EncounterTracker.tsx | 3 +- src/features/player/PlayerViewPage.tsx | 124 +++++++++++++++++++++++ src/features/world/DashboardPage.tsx | 1 + src/lib/db/repositories.ts | 13 +++ src/lib/sync/index.ts | 26 +++++ src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 8 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 e2e/player-view.spec.ts create mode 100644 src/features/player/PlayerViewPage.tsx create mode 100644 src/lib/sync/index.ts diff --git a/e2e/player-view.spec.ts b/e2e/player-view.spec.ts new file mode 100644 index 0000000..78a4498 --- /dev/null +++ b/e2e/player-view.spec.ts @@ -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(); +}); diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index e9936c7..a90f905 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -36,7 +36,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter const mutate = (fn: (e: Encounter) => Encounter) => { undoStack.current.push(encounter); 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 prev = undoStack.current.pop(); diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx new file mode 100644 index 0000000..047be8c --- /dev/null +++ b/src/features/player/PlayerViewPage.tsx @@ -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 {(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 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 ( + +
+
+

{campaign.name}

+

+ Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '} + {localSync.status} +

+
+ +
+ +
+ {/* Party */} +
+

Party

+ {pcs.length === 0 ? ( +

No player characters.

+ ) : ( +
+ {pcs.map((c) => { + const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0; + return ( +
+
+ {c.name} + + Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })} + +
+
+
50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} /> +
+
{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}
+ {c.conditions.length > 0 && ( +
+ {c.conditions.map((cond, i) => ( + {cond.name}{cond.value ? ` ${cond.value}` : ''} + ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Initiative */} +
+

+ Initiative {encounter ? `· Round ${encounter.round}` : ''} +

+ {!encounter ? ( + + ) : ( +
    + {encounter.combatants.map((c, idx) => { + const isCurrent = idx === encounter.turnIndex; + const isPC = c.kind === 'pc'; + const status = enemyStatus(c); + return ( +
  1. + {c.initiative} + {c.name}{isCurrent && ◀ turn} + {isPC ? ( + {c.hp.current}/{c.hp.max} HP + ) : ( + {status.label} + )} +
  2. + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index a06d816..a91cdec 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -22,6 +22,7 @@ const LINKS = [ { to: '/calendar', label: 'Calendar', icon: '📅' }, { to: '/compendium', label: 'Compendium', icon: '📚' }, { to: '/dice', label: 'Dice', icon: '🎲' }, + { to: '/play', label: 'Player View', icon: '📺' }, ] as const; function Dashboard({ campaign }: { campaign: Campaign }) { diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index c32c890..f099fbb 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -164,6 +164,19 @@ export const encountersRepo = { 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 { + 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 { await db.encounters.delete(id); }, diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts new file mode 100644 index 0000000..bfbcf5a --- /dev/null +++ b/src/lib/sync/index.ts @@ -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; + 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() {}, +}; diff --git a/src/router.tsx b/src/router.tsx index 104249c..0622f17 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -12,6 +12,7 @@ import { NpcsPage } from '@/features/world/NpcsPage'; import { QuestsPage } from '@/features/world/QuestsPage'; import { CalendarPage } from '@/features/world/CalendarPage'; import { MapsPage } from '@/features/world/MapsPage'; +import { PlayerViewPage } from '@/features/player/PlayerViewPage'; 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 calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage }); const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage }); +const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage }); const routeTree = rootRoute.addChildren([ indexRoute, @@ -45,6 +47,7 @@ const routeTree = rootRoute.addChildren([ questsRoute, calendarRoute, mapsRoute, + playRoute, ]); export const router = createRouter({ routeTree, defaultPreload: 'intent' }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9c224e2..3a736b3 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/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"} \ No newline at end of file +{"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"} \ No newline at end of file