Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc0a1f1633 | ||
|
|
119d252d02 | ||
|
|
30712f0e1e | ||
|
|
7d0ea883b0 | ||
|
|
5c062bf944 | ||
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 | ||
|
|
c05a283cf0 | ||
|
|
69ab0c35c0 | ||
|
|
91e23856b4 | ||
|
|
fd03ae48c8 | ||
|
|
96770099f6 | ||
|
|
ebd91ca42a |
@@ -1,3 +1,4 @@
|
||||
TODO.md
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
|
||||
@@ -2,38 +2,51 @@
|
||||
|
||||
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
|
||||
|
||||
## Open
|
||||
|
||||
x caff doent work on fron tpage or big tablet
|
||||
|
||||
x max/caff need to float or at least be availabe in combagt popout
|
||||
|
||||
x save needs bigger save button on char editor
|
||||
|
||||
|
||||
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
|
||||
|
||||
also better vert tab layout - labelt friendly
|
||||
x also better vert tab layout - labelt friendly
|
||||
|
||||
x needs AC for players dude
|
||||
|
||||
and quick entry hp
|
||||
x and quick entry hp
|
||||
|
||||
hp do not carry from encounter to ecnounter!!!
|
||||
x hp do not carry from encounter to ecnounter!!!
|
||||
^feature to add back to campaign character after encounter?
|
||||
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
|
||||
|
||||
hp wont go over max and no temp hp support
|
||||
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
|
||||
|
||||
x encounters cards need clear "in progress"...
|
||||
|
||||
|
||||
|
||||
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
|
||||
npcs.....
|
||||
|
||||
x hp wont go over max and no temp hp support
|
||||
|
||||
|
||||
## monsters per campaign and npcs
|
||||
## or/and ....copy from encoubnter?
|
||||
|
||||
|
||||
|
||||
### dm list - keep active particpant in view (scroll)
|
||||
not sure good way to do this
|
||||
|
||||
|
||||
### npm install warnings cleanup pass
|
||||
lots of updates
|
||||
|
||||
|
||||
## monsters per campaign and npcs
|
||||
## or/and ....copy from encoubnter?
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
{
|
||||
"id": "/display",
|
||||
"short_name": "TTRPG Display",
|
||||
"name": "TTRPG Initiative Tracker — Player Display",
|
||||
"name": "TTRPG Initiative Tracker - Player Display",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"src": "player-favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"src": "player-logo192.png",
|
||||
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/display",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"scope": "/display",
|
||||
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
|
||||
"display": "fullscreen",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#1A202C",
|
||||
"background_color": "#1A202C"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
+1
-1
@@ -19,4 +19,4 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"id": "/",
|
||||
"short_name": "TTRPG Tracker",
|
||||
"name": "TTRPG Initiative Tracker",
|
||||
"icons": [
|
||||
@@ -9,17 +10,16 @@
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
|
||||
"display": "fullscreen",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#2D3748",
|
||||
"background_color": "#1A202C"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,21 +1,30 @@
|
||||
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
|
||||
// Scans App.js + storage adapters + shared modules. Catches unused imports,
|
||||
// Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). Catches unused imports,
|
||||
// dead code, undefined vars before they hit the browser console.
|
||||
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function walkJs(dir) {
|
||||
let out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
|
||||
out = out.concat(walkJs(full));
|
||||
} else if (/\.(js|jsx)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TARGETS = [
|
||||
'src/App.js',
|
||||
'src/storage/contract.js',
|
||||
'src/storage/firebase.js',
|
||||
'src/storage/index.js',
|
||||
'src/storage/server.js',
|
||||
'shared/index.js',
|
||||
'shared/logEvent.js',
|
||||
'shared/turn.js',
|
||||
...walkJs(path.join(ROOT, 'src')),
|
||||
...walkJs(path.join(ROOT, 'shared')),
|
||||
];
|
||||
|
||||
function runEslint(files) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Temp HP: damage hits temp first, then regular.
|
||||
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function setupEnc() {
|
||||
const { ctx } = mockCtx();
|
||||
return {
|
||||
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
|
||||
ctx,
|
||||
};
|
||||
}
|
||||
|
||||
describe('temp HP', () => {
|
||||
test('setTempHp sets tempHp on participant', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
test('setTempHp replaces existing temp (no stacking)', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 3, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
test('setTempHp to 0 clears temp', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 0, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
});
|
||||
|
||||
test('damage: temp absorbs before regular HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(17);
|
||||
});
|
||||
|
||||
test('damage less than temp: only temp reduced', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 10, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(6);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('heal: does not affect temp HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
expect(e.participants[0].currentHp).toBe(18);
|
||||
});
|
||||
|
||||
test('damage exactly equals temp: temp gone, regular intact', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('makeParticipant: tempHp defaults to 0', async () => {
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
expect(p.tempHp).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { endEncounter } = shared;
|
||||
|
||||
function makeMockStorage() {
|
||||
const docs = new Map();
|
||||
const writes = [];
|
||||
return {
|
||||
getDoc: jest.fn(async (p) => docs.get(p) || null),
|
||||
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
|
||||
updateDoc: jest.fn(async (p, patch) => {
|
||||
writes.push({ path: p, patch });
|
||||
const cur = docs.get(p) || {};
|
||||
docs.set(p, { ...cur, ...patch });
|
||||
}),
|
||||
addDoc: jest.fn(async (p, data) => {
|
||||
writes.push({ path: p, patch: data });
|
||||
docs.set(p, data);
|
||||
}),
|
||||
deleteDoc: jest.fn(async () => {}),
|
||||
getCollection: jest.fn(async () => []),
|
||||
_docs: docs,
|
||||
_writes: writes,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEncounter(overrides = {}) {
|
||||
return {
|
||||
id: 'enc1',
|
||||
name: 'Test',
|
||||
campaignId: 'camp1',
|
||||
isStarted: true,
|
||||
isPaused: false,
|
||||
currentTurnParticipantId: 'p1',
|
||||
round: 3,
|
||||
turnOrderIds: ['p1', 'p2'],
|
||||
participants: [
|
||||
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
|
||||
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
|
||||
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('character writeback on endEncounter', () => {
|
||||
test('no writeback when syncCharacters false/missing', async () => {
|
||||
const storage = makeMockStorage();
|
||||
storage._docs.set('campaigns/camp1', {
|
||||
id: 'camp1', name: 'Camp', players: [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
|
||||
],
|
||||
});
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
// no campaign write
|
||||
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||
];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||
expect(campWrite).toBeDefined();
|
||||
const updated = campWrite.patch.players;
|
||||
const fighter = updated.find(p => p.id === 'char1');
|
||||
expect(fighter.defaultMaxHp).toBe(28);
|
||||
expect(fighter.defaultAc).toBe(18);
|
||||
expect(fighter.defaultCurrentHp).toBe(20);
|
||||
const cleric = updated.find(p => p.id === 'char2');
|
||||
expect(cleric.defaultMaxHp).toBe(22);
|
||||
expect(cleric.defaultAc).toBe(16);
|
||||
expect(cleric.defaultCurrentHp).toBe(22);
|
||||
});
|
||||
|
||||
test('writeback skips monsters (no originalCharacterId)', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||
// players array still 1 entry, goblin not added
|
||||
expect(campWrite.patch.players.length).toBe(1);
|
||||
});
|
||||
|
||||
test('undo payload includes old char values for restore', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||
];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
storage._docs.set('logs/log1', null);
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
|
||||
expect(logWrite).toBeDefined();
|
||||
const log = logWrite.patch;
|
||||
expect(log.undo.characterWriteback).toBeDefined();
|
||||
expect(log.undo.characterWriteback).toHaveLength(2);
|
||||
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
|
||||
expect(fighterOld.defaultMaxHp).toBe(30);
|
||||
expect(fighterOld.defaultAc).toBe(17);
|
||||
expect(fighterOld.defaultCurrentHp).toBe(30);
|
||||
});
|
||||
|
||||
test('writeback no-op if character missing from campaign', async () => {
|
||||
const storage = makeMockStorage();
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
// no writeback (no matching chars = no change)
|
||||
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+116
-11
@@ -362,6 +362,7 @@ function makeParticipant(opts) {
|
||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||
hpFormula: opts.hpFormula || null,
|
||||
ac: opts.ac !== undefined ? opts.ac : null,
|
||||
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,14 +371,15 @@ function buildCharacterParticipant(character) {
|
||||
const modifier = character.defaultInitMod || 0;
|
||||
const finalInitiative = initiativeRoll + modifier;
|
||||
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
||||
const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name: character.name,
|
||||
type: 'character',
|
||||
type: character.isNpc ? 'npc' : 'character',
|
||||
originalCharacterId: character.id,
|
||||
initiative: finalInitiative,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
currentHp,
|
||||
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
@@ -622,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
async function setTempHp(encounter, participantId, tempHp, ctx) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const value = Math.max(0, tempHp || 0);
|
||||
const oldValues = { tempHp: participant.tempHp || 0 };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: value } : p
|
||||
);
|
||||
const log = {
|
||||
type: 'set_temp_hp',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} temp HP set to ${value}`,
|
||||
delta: { tempHp: value, oldTempHp: oldValues.tempHp },
|
||||
undo: { oldValues, newValues: { tempHp: value } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, log, ctx);
|
||||
}
|
||||
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||
const ctx = maybeCtx || optionsOrCtx;
|
||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||
@@ -636,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
tempHp: participant.tempHp || 0,
|
||||
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
@@ -647,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
let message = '';
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
const currentTemp = participant.tempHp || 0;
|
||||
|
||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
@@ -655,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
}
|
||||
|
||||
if (changeType === 'damage') {
|
||||
// Temp HP absorbs damage first.
|
||||
let remainingDmg = amount;
|
||||
let tempAfter = currentTemp;
|
||||
if (tempAfter > 0) {
|
||||
const absorbed = Math.min(tempAfter, remainingDmg);
|
||||
tempAfter -= absorbed;
|
||||
remainingDmg -= absorbed;
|
||||
updates.tempHp = tempAfter;
|
||||
}
|
||||
// If fully absorbed by temp, no regular HP change.
|
||||
if (remainingDmg === 0 && participant.currentHp > 0) {
|
||||
const updatedParticipants0 = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: tempAfter } : p
|
||||
);
|
||||
const log0 = {
|
||||
type: 'hp_change',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
|
||||
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
|
||||
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
|
||||
}
|
||||
amount = remainingDmg;
|
||||
const tempBefore = currentTemp;
|
||||
const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
|
||||
if (participant.currentHp === 0) {
|
||||
if (status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
@@ -686,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
const add = options.isCriticalHit ? 2 : 1;
|
||||
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
||||
if (failures >= 3) {
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
} else {
|
||||
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
|
||||
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
|
||||
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
||||
} else if (amount >= participant.currentHp + participant.maxHp) {
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
message = `Massive damage! ${participant.name} instantly killed`;
|
||||
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
||||
} else {
|
||||
const newHp = Math.max(0, participant.currentHp - amount);
|
||||
if (newHp === 0) {
|
||||
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
|
||||
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
@@ -746,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||
const participantId = participant.id;
|
||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||
const currentTemp = participant.tempHp || 0;
|
||||
|
||||
if (changeType === 'damage') {
|
||||
// Temp HP absorbs damage first.
|
||||
let remainingDmg = amount;
|
||||
let tempAfter = currentTemp;
|
||||
if (tempAfter > 0) {
|
||||
const absorbed = Math.min(tempAfter, remainingDmg);
|
||||
tempAfter -= absorbed;
|
||||
remainingDmg -= absorbed;
|
||||
}
|
||||
if (remainingDmg === 0) {
|
||||
const updatedParticipants0 = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: tempAfter } : p
|
||||
);
|
||||
const log0 = {
|
||||
type: 'hp_change', participantId, participantName: participant.name,
|
||||
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
|
||||
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
|
||||
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
|
||||
}
|
||||
amount = remainingDmg;
|
||||
const tempFinal = tempAfter;
|
||||
if (oldValues.status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
@@ -771,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount,
|
||||
const newHp = participant.currentHp - amount;
|
||||
const isMonster = participant.type === 'monster';
|
||||
if (newHp <= 0 && isMonster) {
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
|
||||
} else if (newHp <= 0) {
|
||||
updates = { currentHp: newHp, status: 'down' };
|
||||
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
@@ -1054,6 +1127,38 @@ async function endEncounter(encounter, ctx) {
|
||||
delta: {},
|
||||
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
|
||||
};
|
||||
|
||||
// Character writeback: sync maxHp/ac/currentHp to campaign players if enabled.
|
||||
const wbCampaignId = encounter.campaignId || ctx.campaignId;
|
||||
if ((wbCampaignId || ctx.campaign) && ctx.storage) {
|
||||
let campaign;
|
||||
try {
|
||||
campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`);
|
||||
} catch { campaign = null; }
|
||||
if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) {
|
||||
const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId);
|
||||
const oldValues = [];
|
||||
let changed = false;
|
||||
const updatedPlayers = campaign.players.map(player => {
|
||||
const cp = charParticipants.find(p => p.originalCharacterId === player.id);
|
||||
if (!cp) return player;
|
||||
const oldMaxHp = player.defaultMaxHp;
|
||||
const oldAc = player.defaultAc;
|
||||
const oldCurrentHp = player.defaultCurrentHp;
|
||||
oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp });
|
||||
const newPlayer = { ...player };
|
||||
if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; }
|
||||
if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; }
|
||||
if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; }
|
||||
return newPlayer;
|
||||
});
|
||||
if (changed) {
|
||||
await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers });
|
||||
log.undo.characterWriteback = oldValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
@@ -1082,7 +1187,7 @@ module.exports = {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+541
-136
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
|
||||
// Safe default: any value other than exactly '1' = off.
|
||||
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
|
||||
// at build time — allows tests + runtime env changes to take effect.
|
||||
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
|
||||
// Dynamic key access (process.env[KEY]) so react-scripts DefinePlugin does NOT
|
||||
// inline the value at build time — allows tests + runtime env changes to take
|
||||
// effect. DefinePlugin only replaces static member access, never computed lookups.
|
||||
const KEY = 'REACT_APP_DEV_TOOLS';
|
||||
export const isDevToolsEnabled = () => process.env[KEY] === '1';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Display guard: starting encounter does NOT steal display from another live encounter.
|
||||
// Ending encounter does NOT clear display if different encounter showing.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||
|
||||
const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||
|
||||
function findCallActiveDisplay(fn) {
|
||||
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||
}
|
||||
|
||||
describe('Display guard', () => {
|
||||
test('startEncounter: claims display when slot empty', async () => {
|
||||
await setupReady('GuardCamp1', 'GuardEnc1');
|
||||
await addMonsterViaUI('Goblin', 10, 5);
|
||||
await startCombatViaUI();
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last).toBeTruthy();
|
||||
expect(last.data.activeEncounterId).toBeTruthy();
|
||||
});
|
||||
|
||||
test('startEncounter: does NOT steal display from another live encounter', async () => {
|
||||
// Pre-seed display as busy with a DIFFERENT encounter
|
||||
MOCK_DB.set(DISPLAY_PATH, {
|
||||
activeCampaignId: 'other-camp',
|
||||
activeEncounterId: 'other-enc',
|
||||
});
|
||||
|
||||
await setupReady('GuardCamp2', 'GuardEnc2');
|
||||
await addMonsterViaUI('Orc', 15, 8);
|
||||
await startCombatViaUI();
|
||||
|
||||
// Combat started on encounter doc
|
||||
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
expect(encCalls.some(c => c.data.isStarted === true)).toBe(true);
|
||||
|
||||
// But display setDoc should NOT have been called to claim this encounter
|
||||
const adClaims = findCallActiveDisplay('setDoc').filter(
|
||||
c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc'
|
||||
);
|
||||
expect(adClaims).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('endEncounter: clears display when ending the displayed encounter', async () => {
|
||||
await setupReady('GuardCamp3', 'GuardEnc3');
|
||||
await addMonsterViaUI('Wolf', 8, 3);
|
||||
await startCombatViaUI();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && last.data && last.data.activeCampaignId === null;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('endEncounter: does NOT clear display when different encounter is live', async () => {
|
||||
await setupReady('GuardCamp4', 'GuardEnc4');
|
||||
await addMonsterViaUI('Bat', 4, 2);
|
||||
await startCombatViaUI();
|
||||
|
||||
// Now hijack display to a different encounter (simulating eyeball toggle elsewhere)
|
||||
MOCK_DB.set(DISPLAY_PATH, {
|
||||
activeCampaignId: 'different-camp',
|
||||
activeEncounterId: 'different-enc',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
// Wait for encounter to end
|
||||
await waitFor(() => {
|
||||
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const last = encCalls[encCalls.length - 1];
|
||||
return last && last.data.isStarted === false;
|
||||
});
|
||||
|
||||
// Display should NOT have been cleared
|
||||
const clearCalls = findCallActiveDisplay('setDoc').filter(
|
||||
c => c.data && c.data.activeCampaignId === null
|
||||
);
|
||||
expect(clearCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ function participant(status) {
|
||||
|
||||
describe('DisplayView characterization', () => {
|
||||
beforeEach(() => {
|
||||
const favicon = document.createElement('link');
|
||||
favicon.rel = 'icon';
|
||||
favicon.href = '/favicon.ico';
|
||||
document.head.appendChild(favicon);
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
test('display route swaps browser favicon to player icon', async () => {
|
||||
seedActiveDisplay();
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('link[rel="icon"]')).toHaveAttribute('href', '/player-favicon.ico');
|
||||
});
|
||||
});
|
||||
|
||||
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||
seedActiveDisplay();
|
||||
render(<App />);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
|
||||
const addOne = async (name, hp, mod, init) => {
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
|
||||
// Two tabs on different campaigns don't fight over selection/scroll.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupReady, addMonsterViaUI } from './testHelpers';
|
||||
|
||||
describe('localStorage campaign scoping', () => {
|
||||
test('selectedEncounter key is scoped (not bare)', async () => {
|
||||
await setupReady('ScopeCamp1', 'ScopeEnc1');
|
||||
// bare key should not exist
|
||||
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
|
||||
// scoped key with suffix should exist
|
||||
const scoped = Object.keys(localStorage).find(
|
||||
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
|
||||
);
|
||||
expect(scoped).toBeTruthy();
|
||||
expect(localStorage.getItem(scoped)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('scrollY key is scoped (not bare)', async () => {
|
||||
await setupReady('ScopeCamp2', 'ScopeEnc2');
|
||||
await addMonsterViaUI('Goblin', 10, 5);
|
||||
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
|
||||
const scoped = Object.keys(localStorage).find(
|
||||
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
|
||||
);
|
||||
// key may not exist until save fires — trigger by checking bare is null
|
||||
// and at least scoped format is used if any scrollY key present
|
||||
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
|
||||
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
|
||||
});
|
||||
|
||||
test('global keys still exist (campaign selection, wake lock)', async () => {
|
||||
await setupReady('ScopeCamp3', 'ScopeEnc3');
|
||||
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,7 @@ describe('Participant -> Firebase', () => {
|
||||
test('toggleActive: updateDoc flips isActive', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Toggle', 10, 0);
|
||||
fireEvent.click(screen.getByTitle('Mark Inactive'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable participant' }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
|
||||
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
|
||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Cond', 10, 0);
|
||||
fireEvent.click(screen.getByTitle('Conditions'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open conditions' }));
|
||||
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupReady, addMonsterViaUI } from './testHelpers';
|
||||
|
||||
describe('participant action selected states', () => {
|
||||
test('active toggle clearly changes to selected inactive state', async () => {
|
||||
await setupReady('ActionCamp1', 'ActionEnc1');
|
||||
await addMonsterViaUI('Toggle Orc', 15, 3);
|
||||
|
||||
const disable = screen.getByRole('button', { name: 'Disable participant' });
|
||||
expect(disable).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(disable).toHaveClass('bg-emerald-700');
|
||||
fireEvent.click(disable);
|
||||
|
||||
const enable = await screen.findByRole('button', { name: 'Enable participant' });
|
||||
expect(enable).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(enable).toHaveClass('bg-red-900', 'ring-red-500');
|
||||
});
|
||||
|
||||
test('conditions toggle highlights open state and gives close action', async () => {
|
||||
await setupReady('ActionCamp2', 'ActionEnc2');
|
||||
await addMonsterViaUI('Condition Orc', 15, 3);
|
||||
|
||||
const open = screen.getByRole('button', { name: 'Open conditions' });
|
||||
expect(open).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(open);
|
||||
|
||||
const close = screen.getByRole('button', { name: 'Close conditions' });
|
||||
expect(close).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(close).toHaveClass('bg-purple-700', 'ring-purple-300');
|
||||
});
|
||||
|
||||
test('edit toggle exposes selected state while editor is open', async () => {
|
||||
await setupReady('ActionCamp3', 'ActionEnc3');
|
||||
await addMonsterViaUI('Edit Orc', 15, 3);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit participant' }));
|
||||
const closeEditor = screen.getByRole('button', { name: 'Close participant editor' });
|
||||
expect(closeEditor).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(closeEditor).toHaveClass('bg-amber-500', 'ring-amber-200');
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
|
||||
async function addOne(form, name, hp, mod, init) {
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
|
||||
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => {
|
||||
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
|
||||
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||
|
||||
// open edit modal for Goblin, bump init to 8
|
||||
const editBtns = screen.getAllByTitle('Edit');
|
||||
const editBtns = screen.getAllByRole('button', { name: 'Edit participant' });
|
||||
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
|
||||
fireEvent.click(goblinEdit);
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupReady } from './testHelpers';
|
||||
|
||||
describe('screen controls', () => {
|
||||
let originalRequestFullscreen;
|
||||
let originalExitFullscreen;
|
||||
let originalFullscreenElement;
|
||||
let originalVisibilityState;
|
||||
let originalWakeLock;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
originalRequestFullscreen = document.documentElement.requestFullscreen;
|
||||
originalExitFullscreen = document.exitFullscreen;
|
||||
originalFullscreenElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
originalWakeLock = Object.getOwnPropertyDescriptor(navigator, 'wakeLock');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.requestFullscreen = originalRequestFullscreen;
|
||||
document.exitFullscreen = originalExitFullscreen;
|
||||
if (originalFullscreenElement) Object.defineProperty(document, 'fullscreenElement', originalFullscreenElement);
|
||||
else Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
|
||||
if (originalVisibilityState) Object.defineProperty(document, 'visibilityState', originalVisibilityState);
|
||||
if (originalWakeLock) Object.defineProperty(navigator, 'wakeLock', originalWakeLock);
|
||||
else Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: undefined });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('encounter header exposes prevent-sleep, browser-fullscreen, and popout controls', async () => {
|
||||
await setupReady('ScreenCamp', 'ScreenEnc');
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'Prevent sleep' }).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByRole('button', { name: 'Enter browser fullscreen' })).toHaveLength(1);
|
||||
expect(screen.getByRole('button', { name: 'Expand encounter to full page' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('re-acquires wake lock when tablet becomes visible after browser release', async () => {
|
||||
let releaseListener;
|
||||
const sentinel = {
|
||||
released: false,
|
||||
release: jest.fn().mockResolvedValue(undefined),
|
||||
addEventListener: jest.fn((event, callback) => { if (event === 'release') releaseListener = callback; }),
|
||||
};
|
||||
const request = jest.fn().mockResolvedValue(sentinel);
|
||||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } });
|
||||
let visibility = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
|
||||
|
||||
await setupReady('WakeCamp', 'WakeEnc');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Prevent sleep' })[0]);
|
||||
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
|
||||
visibility = 'hidden';
|
||||
releaseListener();
|
||||
visibility = 'visible';
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('character editor has labeled tablet-sized save button', async () => {
|
||||
await setupReady('SaveCamp', 'SaveEnc');
|
||||
fireEvent.change(screen.getByLabelText('Name', { selector: '#characterName' }), { target: { value: 'Tablet Hero' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Character' }));
|
||||
await screen.findByText('Tablet Hero');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit character' }));
|
||||
const save = screen.getByRole('button', { name: 'Save character' });
|
||||
expect(save).toHaveTextContent('Save');
|
||||
expect(save).toHaveClass('min-h-[40px]');
|
||||
});
|
||||
|
||||
test('attempts to restore fullscreen when tablet becomes visible after lock', async () => {
|
||||
const requestFullscreen = jest.fn().mockResolvedValue(undefined);
|
||||
document.documentElement.requestFullscreen = requestFullscreen;
|
||||
document.exitFullscreen = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null });
|
||||
let visibility = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
|
||||
|
||||
await setupReady('RestoreCamp', 'RestoreEnc');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter browser fullscreen' }));
|
||||
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1));
|
||||
|
||||
visibility = 'hidden';
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
visibility = 'visible';
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
|
||||
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(maxHp) } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||
await waitFor(() => {
|
||||
|
||||
Reference in New Issue
Block a user