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:
@@ -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) }),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user