From 5527b7aa7a9f83a415e83a2f579ff1911dc535cc Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 16:17:05 +0200 Subject: [PATCH] P11: hide fogged tokens from players + show combat conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toPlayerProjection now drops tokens whose footprint is unrevealed when fog is on, so a token's name/position stays hidden in the dark (tested). - Player initiative view shows each combatant's visible conditions (playerCombatant + wire schema + PlayerBoards chips) — monster conditions are now visible to players. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/player/PlayerBoards.tsx | 11 ++++++++--- src/lib/combat/playerProjection.ts | 3 +++ src/lib/map/projection.test.ts | 25 +++++++++++++++++++++++++ src/lib/map/projection.ts | 9 ++++++++- src/lib/sync/messages.test.ts | 2 +- src/lib/sync/messages.ts | 1 + 6 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 src/lib/map/projection.test.ts diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx index 4bc60f3..3e75845 100644 --- a/src/features/player/PlayerBoards.tsx +++ b/src/features/player/PlayerBoards.tsx @@ -73,11 +73,16 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; {encounter.combatants.map((c) => (
  • {c.initiative} - {c.name}{c.isCurrent && ◀ turn} + + {c.name}{c.isCurrent && ◀ turn} + {c.conditions.map((cond, i) => ( + {cond.name}{cond.value ? ` ${cond.value}` : ''} + ))} + {c.hp ? ( - {c.hp.current}/{c.hp.max} HP + {c.hp.current}/{c.hp.max} HP ) : c.status ? ( - {c.status.label} + {c.status.label} ) : null}
  • ))} diff --git a/src/lib/combat/playerProjection.ts b/src/lib/combat/playerProjection.ts index 063f748..e7556ef 100644 --- a/src/lib/combat/playerProjection.ts +++ b/src/lib/combat/playerProjection.ts @@ -22,6 +22,8 @@ export interface PlayerCombatant { /** exact HP for PCs; undefined for hidden enemies */ hp?: { current: number; max: number; temp: number }; status?: EnemyStatus; + /** visible conditions (shown to players for everyone in the fight) */ + conditions: { name: string; value?: number }[]; } export interface PlayerEncounter { @@ -45,6 +47,7 @@ export function toPlayerEncounter(e: Encounter): PlayerEncounter { kind: c.kind, initiative: c.initiative, isCurrent: idx === e.turnIndex, + conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })), ...(isPC ? { hp: c.hp } : { status: enemyStatus(c) }), }; }), diff --git a/src/lib/map/projection.test.ts b/src/lib/map/projection.test.ts new file mode 100644 index 0000000..e218f5b --- /dev/null +++ b/src/lib/map/projection.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { battleMapSchema } from '@/lib/schemas'; +import { toPlayerProjection } from './projection'; + +function map(over: Record) { + return battleMapSchema.parse({ id: 'm', campaignId: 'c', name: 'M', createdAt: 't', updatedAt: 't', gridSize: 50, ...over }); +} + +describe('toPlayerProjection fog visibility', () => { + const tokens = [ + { id: 'lit', label: 'Hero', col: 0, row: 0, size: 1 }, + { id: 'dark', label: 'Lurker', col: 5, row: 5, size: 1 }, + { id: 'secret', label: 'Trap', col: 0, row: 0, size: 1, gmOnly: true }, + ]; + + it('hides tokens whose cells are unrevealed when fog is on', () => { + const p = toPlayerProjection(map({ fogEnabled: true, revealed: ['0,0'], tokens })); + expect(p.tokens.map((t) => t.id)).toEqual(['lit']); // dark = fogged, secret = gmOnly + }); + + it('shows all non-gmOnly tokens when fog is off', () => { + const p = toPlayerProjection(map({ fogEnabled: false, revealed: [], tokens })); + expect(p.tokens.map((t) => t.id).sort()).toEqual(['dark', 'lit']); + }); +}); diff --git a/src/lib/map/projection.ts b/src/lib/map/projection.ts index 4094715..a50581c 100644 --- a/src/lib/map/projection.ts +++ b/src/lib/map/projection.ts @@ -1,4 +1,5 @@ import type { BattleMap } from '@/lib/schemas'; +import { tokenCells } from './grid'; import type { PlayerDrawing, PlayerMap, PlayerToken } from './types'; /** @@ -7,8 +8,14 @@ import type { PlayerDrawing, PlayerMap, PlayerToken } from './types'; * Dexie). Revealed cells pass through (the player renderer fogs the rest). */ export function toPlayerProjection(map: BattleMap): PlayerMap { + // When fog is on, a token is only visible to players if at least one of its + // footprint cells is revealed — so names/positions stay hidden in the dark. + const revealed = new Set(map.revealed); + const visible = (t: BattleMap['tokens'][number]) => + !map.fogEnabled || tokenCells({ col: t.col, row: t.row, size: t.size }).some((k) => revealed.has(k)); + const tokens: PlayerToken[] = map.tokens - .filter((t) => !t.gmOnly) + .filter((t) => !t.gmOnly && visible(t)) .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 } : {}), diff --git a/src/lib/sync/messages.test.ts b/src/lib/sync/messages.test.ts index 7fd66c5..58280e3 100644 --- a/src/lib/sync/messages.test.ts +++ b/src/lib/sync/messages.test.ts @@ -49,7 +49,7 @@ describe('sync protocol', () => { campaignName: 'X', calendarDay: null, party: [{ id: 'a', name: 'Hero', level: 3, ac: 15, hp: { current: 10, max: 20, temp: 0 }, conditions: [] }], encounter: { id: 'e', name: 'Fight', round: 1, combatants: [ - { id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' } }, + { id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' }, conditions: [{ name: 'frightened', value: 1 }] }, ] }, map: null, mapImageId: null, }; diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index c0c9f47..10c0cc8 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -30,6 +30,7 @@ export const playerCombatantSchema = z.object({ isCurrent: z.boolean(), hp: hpSchema.optional(), status: z.object({ label: z.string(), cls: z.string() }).optional(), + conditions: z.array(condSchema).default([]), }); export const playerEncounterSchema = z.object({