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({