P11: hide fogged tokens from players + show combat conditions

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:17:05 +02:00
parent 76efc459bb
commit 5527b7aa7a
6 changed files with 46 additions and 5 deletions
+8 -3
View File
@@ -73,11 +73,16 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{encounter.combatants.map((c) => (
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.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}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
<span className="text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
</span>
{c.hp ? (
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
<span className="shrink-0 text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
) : c.status ? (
<span className={cn('text-sm font-medium', c.status.cls)}>{c.status.label}</span>
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
) : null}
</li>
))}
+3
View File
@@ -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) }),
};
}),
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { battleMapSchema } from '@/lib/schemas';
import { toPlayerProjection } from './projection';
function map(over: Record<string, unknown>) {
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']);
});
});
+8 -1
View File
@@ -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 } : {}),
+1 -1
View File
@@ -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,
};
+1
View File
@@ -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({