Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model: - status: conscious | dying | stable | dead - deathSaveSuccesses/deathSaveFailures as display counters - remove old death-save fields as source of truth - Add death-save actions: - success, fail, nat1, nat20 - stabilize participant - revive dead participant to 0 HP, stable, unconscious - Apply 5e HP transition rules: - characters and NPCs at 0 HP become dying - stable/dying participants gain unconscious condition - healing clears death saves and returns conscious - massive damage kills - non-NPC monsters at 0 HP become dead and inactive - Split NPCs from monsters with type="npc": - NPCs use death saves like characters - NPCs remain DM-controlled/monster-colored in UI - new NPCs no longer persist isNpc flag - Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates - Allow DM active/inactive toggle for dead participants - Hide inactive participants from player display - Improve DM and player death-state UI: - show Dying/Dead/Unconscious states consistently - add revive button for dead participants - distinguish dead visual from inactive visual in DM view - Fix initiative drag/reorder behavior: - downward drag now changes order instead of no-op - paused combat can reorder across current turn pointer - turnOrderIds stay synced to participants order - Persist campaign collapse state in localStorage - Update death-save docs and encounter builder docs - Add/expand tests for: - death saves and HP transitions - dead/active/inactive behavior - NPC death-save behavior - player display visibility - drag reorder semantics - logging expectations Tests: - npm run test:all - app: 94 passed - shared: 158 passed, 5 skipped - server: 32 passed
This commit is contained in:
@@ -4,29 +4,46 @@
|
||||
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import App from '../App';
|
||||
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||
|
||||
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
||||
function seedActiveDisplay() {
|
||||
function seedActiveDisplay(participants = []) {
|
||||
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||
|
||||
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
|
||||
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||
}
|
||||
|
||||
function participant(status) {
|
||||
return {
|
||||
id: status,
|
||||
name: status === 'dying' ? 'Rogue' : status,
|
||||
type: 'character',
|
||||
initiative: 18,
|
||||
maxHp: 160,
|
||||
currentHp: status === 'conscious' ? 10 : 0,
|
||||
isActive: true,
|
||||
status,
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DisplayView characterization', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
resetAdapterCalls();
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
|
||||
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
|
||||
seedActiveDisplay([participant('dying')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
|
||||
seedActiveDisplay([participant('stable'), participant('dead')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView hides inactive participants for all types', async () => {
|
||||
seedActiveDisplay([
|
||||
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
||||
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
||||
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user