Compare commits
9
Commits
c05a283cf0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23aa99ba0a | ||
|
|
dc0a1f1633 | ||
|
|
119d252d02 | ||
|
|
30712f0e1e | ||
|
|
7d0ea883b0 | ||
|
|
5c062bf944 | ||
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 |
@@ -1,3 +1,4 @@
|
|||||||
|
TODO.md
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -2,44 +2,51 @@
|
|||||||
|
|
||||||
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||||
|
|
||||||
|
|
||||||
## Open
|
## 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
|
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
|
x needs AC for players dude
|
||||||
|
|
||||||
x 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?
|
^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)
|
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 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"...
|
||||||
|
|
||||||
I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
|
|
||||||
|
|
||||||
|
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
|
||||||
npcs.....
|
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
|
### npm install warnings cleanup pass
|
||||||
lots of updates
|
lots of updates
|
||||||
|
|
||||||
|
|
||||||
## monsters per campaign and npcs
|
|
||||||
## or/and ....copy from encoubnter?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/display",
|
||||||
"short_name": "TTRPG Display",
|
"short_name": "TTRPG Display",
|
||||||
"name": "TTRPG Initiative Tracker — Player Display",
|
"name": "TTRPG Initiative Tracker - Player Display",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "player-favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"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",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/display",
|
"start_url": "/display",
|
||||||
"scope": "/",
|
"scope": "/display",
|
||||||
"display": "standalone",
|
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
|
||||||
"orientation": "landscape",
|
"display": "fullscreen",
|
||||||
|
"orientation": "portrait",
|
||||||
"theme_color": "#1A202C",
|
"theme_color": "#1A202C",
|
||||||
"background_color": "#1A202C"
|
"background_color": "#1A202C"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
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",
|
"short_name": "TTRPG Tracker",
|
||||||
"name": "TTRPG Initiative Tracker",
|
"name": "TTRPG Initiative Tracker",
|
||||||
"icons": [
|
"icons": [
|
||||||
@@ -9,17 +10,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"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",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"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",
|
"theme_color": "#2D3748",
|
||||||
"background_color": "#1A202C"
|
"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.
|
// 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.
|
// dead code, undefined vars before they hit the browser console.
|
||||||
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..', '..');
|
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 = [
|
const TARGETS = [
|
||||||
'src/App.js',
|
...walkJs(path.join(ROOT, 'src')),
|
||||||
'src/storage/contract.js',
|
...walkJs(path.join(ROOT, 'shared')),
|
||||||
'src/storage/firebase.js',
|
|
||||||
'src/storage/index.js',
|
|
||||||
'src/storage/server.js',
|
|
||||||
'shared/index.js',
|
|
||||||
'shared/logEvent.js',
|
|
||||||
'shared/turn.js',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function runEslint(files) {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
-10
@@ -362,6 +362,7 @@ function makeParticipant(opts) {
|
|||||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||||
hpFormula: opts.hpFormula || null,
|
hpFormula: opts.hpFormula || null,
|
||||||
ac: opts.ac !== undefined ? opts.ac : null,
|
ac: opts.ac !== undefined ? opts.ac : null,
|
||||||
|
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +375,7 @@ function buildCharacterParticipant(character) {
|
|||||||
return {
|
return {
|
||||||
participant: makeParticipant({
|
participant: makeParticipant({
|
||||||
name: character.name,
|
name: character.name,
|
||||||
type: 'character',
|
type: character.isNpc ? 'npc' : 'character',
|
||||||
originalCharacterId: character.id,
|
originalCharacterId: character.id,
|
||||||
initiative: finalInitiative,
|
initiative: finalInitiative,
|
||||||
maxHp,
|
maxHp,
|
||||||
@@ -623,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
|||||||
return commit(encounter, patch, log, 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) {
|
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||||
const ctx = maybeCtx || optionsOrCtx;
|
const ctx = maybeCtx || optionsOrCtx;
|
||||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||||
@@ -637,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
|
|
||||||
const oldValues = {
|
const oldValues = {
|
||||||
currentHp: participant.currentHp,
|
currentHp: participant.currentHp,
|
||||||
|
tempHp: participant.tempHp || 0,
|
||||||
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
||||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||||
@@ -648,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
let message = '';
|
let message = '';
|
||||||
let logDelta = { amount, from: participant.currentHp };
|
let logDelta = { amount, from: participant.currentHp };
|
||||||
const status = oldValues.status;
|
const status = oldValues.status;
|
||||||
|
const currentTemp = participant.tempHp || 0;
|
||||||
|
|
||||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||||
@@ -656,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changeType === 'damage') {
|
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 (participant.currentHp === 0) {
|
||||||
if (status === 'dead') {
|
if (status === 'dead') {
|
||||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||||
@@ -687,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
const add = options.isCriticalHit ? 2 : 1;
|
const add = options.isCriticalHit ? 2 : 1;
|
||||||
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
||||||
if (failures >= 3) {
|
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 {
|
} 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'}`;
|
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
|
||||||
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
||||||
} else if (amount >= participant.currentHp + participant.maxHp) {
|
} 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`;
|
message = `Massive damage! ${participant.name} instantly killed`;
|
||||||
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
||||||
} else {
|
} else {
|
||||||
const newHp = Math.max(0, participant.currentHp - amount);
|
const newHp = Math.max(0, participant.currentHp - amount);
|
||||||
if (newHp === 0) {
|
if (newHp === 0) {
|
||||||
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
|
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 {
|
} else {
|
||||||
updates = { currentHp: newHp, status: 'conscious' };
|
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||||
}
|
}
|
||||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||||
@@ -747,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||||
const participantId = participant.id;
|
const participantId = participant.id;
|
||||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||||
|
const currentTemp = participant.tempHp || 0;
|
||||||
|
|
||||||
if (changeType === 'damage') {
|
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 (oldValues.status === 'dead') {
|
||||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
@@ -772,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount,
|
|||||||
const newHp = participant.currentHp - amount;
|
const newHp = participant.currentHp - amount;
|
||||||
const isMonster = participant.type === 'monster';
|
const isMonster = participant.type === 'monster';
|
||||||
if (newHp <= 0 && isMonster) {
|
if (newHp <= 0 && isMonster) {
|
||||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
|
||||||
} else if (newHp <= 0) {
|
} else if (newHp <= 0) {
|
||||||
updates = { currentHp: newHp, status: 'down' };
|
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
|
||||||
} else {
|
} else {
|
||||||
updates = { currentHp: newHp, status: 'conscious' };
|
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||||
}
|
}
|
||||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||||
@@ -1115,7 +1187,7 @@ module.exports = {
|
|||||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||||
startEncounter, nextTurn, togglePause,
|
startEncounter, nextTurn, togglePause,
|
||||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||||
reorderParticipants, endEncounter,
|
reorderParticipants, endEncounter,
|
||||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||||
};
|
};
|
||||||
|
|||||||
+229
-56
@@ -8,7 +8,7 @@ import {
|
|||||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
|
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
|
||||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
Maximize2, Minimize2, PanelTopOpen, PanelTopClose, Moon, Coffee, Clock, ChevronRight, X,
|
||||||
Undo2, Redo2, Crosshair
|
Undo2, Redo2, Crosshair
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||||
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
||||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||||
|
const [tempHp, setTempHp] = useState(participant.tempHp || 0);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
maxHp: finalMaxHp,
|
maxHp: finalMaxHp,
|
||||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||||
ac: ac !== '' ? parseInt(ac, 10) : null,
|
ac: ac !== '' ? parseInt(ac, 10) : null,
|
||||||
|
tempHp: Math.max(0, parseInt(tempHp, 10) || 0),
|
||||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-stone-300">Temp HP</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tempHp}
|
||||||
|
onChange={(e) => setTempHp(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-cyan-600 focus:border-cyan-600 sm:text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
</div>
|
||||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
||||||
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||||
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
||||||
const [defaultAc, setDefaultAc] = useState('');
|
const [defaultAc, setDefaultAc] = useState('');
|
||||||
|
const [isCharacterNpc, setIsCharacterNpc] = useState(false);
|
||||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
@@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
name: characterName.trim(),
|
name: characterName.trim(),
|
||||||
defaultMaxHp: hp,
|
defaultMaxHp: hp,
|
||||||
defaultInitMod: initMod,
|
defaultInitMod: initMod,
|
||||||
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
|
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null,
|
||||||
|
isNpc: isCharacterNpc || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
setDefaultMaxHp(DEFAULT_MAX_HP);
|
setDefaultMaxHp(DEFAULT_MAX_HP);
|
||||||
setDefaultInitMod(DEFAULT_INIT_MOD);
|
setDefaultInitMod(DEFAULT_INIT_MOD);
|
||||||
setDefaultAc('');
|
setDefaultAc('');
|
||||||
|
setIsCharacterNpc(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error adding character:", err);
|
console.error("Error adding character:", err);
|
||||||
showToast("Failed to add character. Please try again."); }
|
showToast("Failed to add character. Please try again."); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => {
|
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => {
|
||||||
if (!db || !newName.trim() || !campaignId) return;
|
if (!db || !newName.trim() || !campaignId) return;
|
||||||
|
|
||||||
const hp = parseInt(newDefaultMaxHp, 10);
|
const hp = parseInt(newDefaultMaxHp, 10);
|
||||||
@@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
|
|
||||||
const updatedCharacters = campaignCharacters.map(c =>
|
const updatedCharacters = campaignCharacters.map(c =>
|
||||||
c.id === characterId
|
c.id === characterId
|
||||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null }
|
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) }
|
||||||
: c
|
: c
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="col-span-2 sm:col-span-12 flex items-center gap-2 text-xs font-medium text-stone-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isCharacterNpc}
|
||||||
|
onChange={(e) => setIsCharacterNpc(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-amber-600"
|
||||||
|
/>
|
||||||
|
Long-lived NPC (death saves like character, red name in combat)
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
|
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
|
||||||
@@ -959,7 +987,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
editingCharacter.defaultMaxHp,
|
editingCharacter.defaultMaxHp,
|
||||||
editingCharacter.defaultInitMod,
|
editingCharacter.defaultInitMod,
|
||||||
editingCharacter.defaultAc,
|
editingCharacter.defaultAc,
|
||||||
editingCharacter.defaultCurrentHp
|
editingCharacter.defaultCurrentHp,
|
||||||
|
editingCharacter.isNpc
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||||
@@ -1013,8 +1042,21 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
title="AC"
|
title="AC"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
<label className="flex items-center gap-1 text-[10px] text-stone-400" title="Long-lived NPC">
|
||||||
<Save size={18} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingCharacter.isNpc || false}
|
||||||
|
onChange={(e) => setEditingCharacter({ ...editingCharacter, isNpc: e.target.checked })}
|
||||||
|
className="w-3 h-3 accent-red-600"
|
||||||
|
/>
|
||||||
|
NPC
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white font-semibold text-sm min-h-[40px]"
|
||||||
|
aria-label="Save character"
|
||||||
|
>
|
||||||
|
<Save size={18} /> Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1028,6 +1070,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center mr-auto">
|
<div className="flex items-center mr-auto">
|
||||||
<span className="text-stone-100 mr-2">{character.name}</span>
|
<span className="text-stone-100 mr-2">{character.name}</span>
|
||||||
|
{character.isNpc && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-red-900 text-red-200 text-xs font-medium border border-red-600" title="Long-lived NPC">NPC</span>
|
||||||
|
)}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
|
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
|
||||||
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
|
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
|
||||||
@@ -1376,6 +1421,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInlineTempHp = async (participantId, value) => {
|
||||||
|
const n = Math.max(0, parseInt(value, 10) || 0);
|
||||||
|
try {
|
||||||
|
await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath));
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to update Temp HP.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeathSaveChange = async (participantId, outcome) => {
|
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
@@ -1713,7 +1767,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
const isGeneric = (encounter.ruleset || '5e') === 'generic';
|
const isGeneric = (encounter.ruleset || '5e') === 'generic';
|
||||||
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
|
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
|
||||||
|
|
||||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
let bgColor = !p.isActive ? 'bg-stone-800' : (p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]');
|
||||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||||
|
|
||||||
const participantStatus = p.status || (isGeneric
|
const participantStatus = p.status || (isGeneric
|
||||||
@@ -1731,7 +1785,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
onDragOver={isDraggable ? handleDragOver : undefined}
|
onDragOver={isDraggable ? handleDragOver : undefined}
|
||||||
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
|
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
|
||||||
onDragEnd={() => setDraggedItemId(null)}
|
onDragEnd={() => setDraggedItemId(null)}
|
||||||
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
|
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'ring-2 ring-stone-500' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
|
||||||
>
|
>
|
||||||
{editingId === p.id && (
|
{editingId === p.id && (
|
||||||
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||||
@@ -1778,7 +1832,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
||||||
</p>
|
</p>
|
||||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center px-0.5 border border-amber-500 bg-stone-950 text-amber-300 rounded">
|
||||||
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
|
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -1792,7 +1846,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
||||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
|
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
||||||
className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
aria-label={`Initiative for ${p.name}`}
|
aria-label={`Initiative for ${p.name}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -1816,10 +1870,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
|
||||||
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
|
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
|
||||||
className="w-10 bg-transparent text-stone-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
aria-label={`Max HP for ${p.name}`}
|
aria-label={`Max HP for ${p.name}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="inline-flex items-baseline gap-0.5 ml-1" title="Temporary HP">
|
||||||
|
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide">+</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue={p.tempHp || 0}
|
||||||
|
key={`temphp-${p.tempHp || 0}`}
|
||||||
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }}
|
||||||
|
onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }}
|
||||||
|
className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`}
|
||||||
|
aria-label={`Temp HP for ${p.name}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span className="ml-auto inline-flex items-center gap-1">
|
<span className="ml-auto inline-flex items-center gap-1">
|
||||||
{participantStatus === 'dead' && (
|
{participantStatus === 'dead' && (
|
||||||
<button onClick={() => handleRevive(p.id)} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
|
<button onClick={() => handleRevive(p.id)} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
|
||||||
@@ -1999,22 +2066,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleParticipantActive(p.id)}
|
onClick={() => toggleParticipantActive(p.id)}
|
||||||
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
|
className={`p-1 rounded transition-all ring-2 ${p.isActive ? 'bg-emerald-700 hover:bg-emerald-600 text-white ring-emerald-300' : 'bg-red-900 hover:bg-red-800 text-red-100 ring-red-500'}`}
|
||||||
title={p.isActive ? "Mark Inactive" : "Mark Active"}
|
title={p.isActive ? "Participant active — click to disable" : "Participant inactive — click to enable"}
|
||||||
|
aria-label={p.isActive ? "Disable participant" : "Enable participant"}
|
||||||
|
aria-pressed={p.isActive}
|
||||||
>
|
>
|
||||||
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
|
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
|
||||||
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
|
className={`p-1 rounded transition-all ${openConditionsId === p.id ? 'bg-purple-700 text-white ring-2 ring-purple-300' : (p.conditions || []).length > 0 ? 'bg-stone-700 text-purple-300 ring-1 ring-purple-500 hover:bg-stone-600' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
|
||||||
title="Conditions"
|
title={openConditionsId === p.id ? 'Conditions open — click to close' : 'Open conditions'}
|
||||||
|
aria-label={openConditionsId === p.id ? 'Close conditions' : 'Open conditions'}
|
||||||
|
aria-expanded={openConditionsId === p.id}
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">✨</span>
|
<span className="text-base leading-none">✨</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingParticipant(p)}
|
onClick={() => setEditingParticipant(editingParticipant?.id === p.id ? null : p)}
|
||||||
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
className={`p-1 rounded transition-all ${editingParticipant?.id === p.id ? 'bg-amber-500 text-stone-950 ring-2 ring-amber-200' : 'text-yellow-300 bg-stone-700 hover:bg-stone-600'}`}
|
||||||
title="Edit"
|
title={editingParticipant?.id === p.id ? 'Editing — click to close' : 'Edit participant'}
|
||||||
|
aria-label={editingParticipant?.id === p.id ? 'Close participant editor' : 'Edit participant'}
|
||||||
|
aria-pressed={editingParticipant?.id === p.id}
|
||||||
>
|
>
|
||||||
<Edit3 size={18} />
|
<Edit3 size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -2165,7 +2238,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await startEncounter(encounter, buildCtx(encounterPath));
|
await startEncounter(encounter, buildCtx(encounterPath));
|
||||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
// Only claim player display if slot is empty or already showing this encounter.
|
||||||
|
// Don't steal display from another live encounter.
|
||||||
|
const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId;
|
||||||
|
const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id;
|
||||||
|
if (displayEmpty || displayIsOurs) {
|
||||||
|
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Failed to start encounter. Please try again."); }
|
showToast(err.message || "Failed to start encounter. Please try again."); }
|
||||||
};
|
};
|
||||||
@@ -2207,7 +2286,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
await endEncounter(encounter, ctx);
|
await endEncounter(encounter, ctx);
|
||||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
// Only clear display if THIS encounter is the one showing.
|
||||||
|
const displayIsThis = activeDisplayData &&
|
||||||
|
activeDisplayData.activeCampaignId === campaignId &&
|
||||||
|
activeDisplayData.activeEncounterId === encounter.id;
|
||||||
|
if (displayIsThis) {
|
||||||
|
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to end encounter. Please try again."); }
|
showToast("Failed to end encounter. Please try again."); }
|
||||||
|
|
||||||
@@ -2327,7 +2412,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
// ENCOUNTER MANAGER COMPONENT
|
// ENCOUNTER MANAGER COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef, wakeLockEnabled, onToggleWakeLock, isBrowserFullscreen, onToggleBrowserFullscreen }) {
|
||||||
const { showToast } = useUIFeedback();
|
const { showToast } = useUIFeedback();
|
||||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||||
campaignId ? getPath.encounters(campaignId) : null
|
campaignId ? getPath.encounters(campaignId) : null
|
||||||
@@ -2337,7 +2422,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const [encounters, setEncounters] = useState([]);
|
const [encounters, setEncounters] = useState([]);
|
||||||
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
||||||
try { return localStorage.getItem('ttrpg.selectedEncounter') || null; }
|
try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; }
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
});
|
});
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@@ -2348,6 +2433,17 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||||
|
|
||||||
|
// Restore scoped encounter selection when campaign changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!campaignId) { setSelectedEncounterId(null); return; }
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null;
|
||||||
|
setSelectedEncounterId(saved);
|
||||||
|
} catch {
|
||||||
|
setSelectedEncounterId(null);
|
||||||
|
}
|
||||||
|
}, [campaignId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (encountersData) setEncounters(encountersData);
|
if (encountersData) setEncounters(encountersData);
|
||||||
}, [encountersData]);
|
}, [encountersData]);
|
||||||
@@ -2394,10 +2490,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedEncounterIdRef.current = selectedEncounterId;
|
selectedEncounterIdRef.current = selectedEncounterId;
|
||||||
try {
|
try {
|
||||||
if (selectedEncounterId) localStorage.setItem('ttrpg.selectedEncounter', selectedEncounterId);
|
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
|
||||||
else localStorage.removeItem('ttrpg.selectedEncounter');
|
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [selectedEncounterId]);
|
}, [selectedEncounterId, campaignId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!campaignId) {
|
if (!campaignId) {
|
||||||
@@ -2622,15 +2718,34 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
{selectedEncounter && (
|
{selectedEncounter && (
|
||||||
<div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}>
|
<div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}>
|
||||||
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between">
|
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between gap-2">
|
||||||
<span>Managing Encounter: {selectedEncounter.name}</span>
|
<span className="min-w-0 truncate">Managing Encounter: {selectedEncounter.name}</span>
|
||||||
<button
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
onClick={() => setEncounterFullscreen(v => !v)}
|
<button
|
||||||
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0"
|
onClick={onToggleWakeLock}
|
||||||
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'}
|
className={`p-1.5 rounded-md transition-colors ${wakeLockEnabled ? 'bg-amber-600 text-white hover:bg-amber-700' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
|
||||||
>
|
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
||||||
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
aria-label={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
|
||||||
</button>
|
>
|
||||||
|
{wakeLockEnabled ? <Coffee size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleBrowserFullscreen}
|
||||||
|
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
|
||||||
|
title={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
|
||||||
|
aria-label={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
|
||||||
|
>
|
||||||
|
{isBrowserFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEncounterFullscreen(v => !v)}
|
||||||
|
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
|
||||||
|
title={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
|
||||||
|
aria-label={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
|
||||||
|
>
|
||||||
|
{encounterFullscreen ? <PanelTopClose size={18} /> : <PanelTopOpen size={18} />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
{/* Combat Controls - always left */}
|
{/* Combat Controls - always left */}
|
||||||
@@ -2684,21 +2799,43 @@ function AdminView({ userId }) {
|
|||||||
catch { return false; }
|
catch { return false; }
|
||||||
});
|
});
|
||||||
const wakeLockRef = useRef(null);
|
const wakeLockRef = useRef(null);
|
||||||
|
const fullscreenWantedRef = useRef(false);
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = async () => {
|
||||||
if (!document.fullscreenElement) {
|
try {
|
||||||
document.documentElement.requestFullscreen();
|
if (!document.fullscreenElement) {
|
||||||
} else {
|
fullscreenWantedRef.current = true;
|
||||||
document.exitFullscreen();
|
await document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
fullscreenWantedRef.current = false;
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fullscreen failed. Tap fullscreen again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => {
|
||||||
|
const active = !!document.fullscreenElement;
|
||||||
|
setIsFullscreen(active);
|
||||||
|
if (active) fullscreenWantedRef.current = true;
|
||||||
|
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
|
||||||
|
};
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const restoreFullscreen = async () => {
|
||||||
|
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
|
||||||
|
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', restoreFullscreen);
|
||||||
|
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wakeLockEnabled) {
|
if (!wakeLockEnabled) {
|
||||||
wakeLockRef.current?.release();
|
wakeLockRef.current?.release();
|
||||||
@@ -2706,8 +2843,13 @@ function AdminView({ userId }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const acquire = async () => {
|
const acquire = async () => {
|
||||||
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); }
|
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
|
||||||
catch (e) {
|
try {
|
||||||
|
if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
|
||||||
|
const sentinel = await navigator.wakeLock.request('screen');
|
||||||
|
wakeLockRef.current = sentinel;
|
||||||
|
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
|
||||||
|
} catch (e) {
|
||||||
console.error('Wake lock failed:', e);
|
console.error('Wake lock failed:', e);
|
||||||
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
|
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
|
||||||
}
|
}
|
||||||
@@ -2740,7 +2882,7 @@ function AdminView({ userId }) {
|
|||||||
// Save scroll on unload + visibility change + interval.
|
// Save scroll on unload + visibility change + interval.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
try { localStorage.setItem('ttrpg.scrollY', String(window.scrollY)); } catch {}
|
try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {}
|
||||||
};
|
};
|
||||||
window.addEventListener('beforeunload', onSave);
|
window.addEventListener('beforeunload', onSave);
|
||||||
window.addEventListener('pagehide', onSave);
|
window.addEventListener('pagehide', onSave);
|
||||||
@@ -2752,20 +2894,20 @@ function AdminView({ userId }) {
|
|||||||
document.removeEventListener('visibilitychange', onSave);
|
document.removeEventListener('visibilitychange', onSave);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [selectedCampaignId]);
|
||||||
|
|
||||||
// Restore scroll once data loaded.
|
// Restore scroll once data loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRestoredRef.current) return;
|
if (scrollRestoredRef.current) return;
|
||||||
if (campaignsWithDetails.length === 0) return;
|
if (campaignsWithDetails.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const y = parseInt(localStorage.getItem('ttrpg.scrollY') || '0', 10);
|
const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10);
|
||||||
if (y > 0) {
|
if (y > 0) {
|
||||||
scrollRestoredRef.current = true;
|
scrollRestoredRef.current = true;
|
||||||
setTimeout(() => window.scrollTo(0, y), 300);
|
setTimeout(() => window.scrollTo(0, y), 300);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [campaignsWithDetails]);
|
}, [campaignsWithDetails, selectedCampaignId]);
|
||||||
|
|
||||||
// Persist selections across reload.
|
// Persist selections across reload.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3164,6 +3306,10 @@ function AdminView({ userId }) {
|
|||||||
campaignCharacters={selectedCampaign.characters || []}
|
campaignCharacters={selectedCampaign.characters || []}
|
||||||
encounterStartedRef={encounterStartedRef}
|
encounterStartedRef={encounterStartedRef}
|
||||||
encounterActiveRef={encounterActiveRef}
|
encounterActiveRef={encounterActiveRef}
|
||||||
|
wakeLockEnabled={wakeLockEnabled}
|
||||||
|
onToggleWakeLock={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
|
||||||
|
isBrowserFullscreen={isFullscreen}
|
||||||
|
onToggleBrowserFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3264,6 +3410,7 @@ function DisplayView() {
|
|||||||
});
|
});
|
||||||
const [displayParticipants, setDisplayParticipants] = useState([]);
|
const [displayParticipants, setDisplayParticipants] = useState([]);
|
||||||
const wakeLockRef = useRef(null);
|
const wakeLockRef = useRef(null);
|
||||||
|
const fullscreenWantedRef = useRef(false);
|
||||||
const currentParticipantRef = useRef(null);
|
const currentParticipantRef = useRef(null);
|
||||||
|
|
||||||
// Player display transition state. Active participants render normally.
|
// Player display transition state. Active participants render normally.
|
||||||
@@ -3294,19 +3441,40 @@ function DisplayView() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => {
|
||||||
|
const active = !!document.fullscreenElement;
|
||||||
|
setIsFullscreen(active);
|
||||||
|
if (active) fullscreenWantedRef.current = true;
|
||||||
|
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
|
||||||
|
};
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = async () => {
|
||||||
if (!document.fullscreenElement) {
|
try {
|
||||||
document.documentElement.requestFullscreen();
|
if (!document.fullscreenElement) {
|
||||||
} else {
|
fullscreenWantedRef.current = true;
|
||||||
document.exitFullscreen();
|
await document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
fullscreenWantedRef.current = false;
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fullscreen failed:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const restoreFullscreen = async () => {
|
||||||
|
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
|
||||||
|
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', restoreFullscreen);
|
||||||
|
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wakeLockEnabled) {
|
if (!wakeLockEnabled) {
|
||||||
wakeLockRef.current?.release();
|
wakeLockRef.current?.release();
|
||||||
@@ -3315,8 +3483,12 @@ function DisplayView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const acquire = async () => {
|
const acquire = async () => {
|
||||||
|
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
|
||||||
try {
|
try {
|
||||||
wakeLockRef.current = await navigator.wakeLock.request('screen');
|
if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
|
||||||
|
const sentinel = await navigator.wakeLock.request('screen');
|
||||||
|
wakeLockRef.current = sentinel;
|
||||||
|
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Wake lock failed:', e);
|
console.error('Wake lock failed:', e);
|
||||||
}
|
}
|
||||||
@@ -3324,10 +3496,9 @@ function DisplayView() {
|
|||||||
|
|
||||||
acquire();
|
acquire();
|
||||||
|
|
||||||
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
|
// Re-acquire after tab becomes visible again (browser auto-releases on hide).
|
||||||
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
|
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
|
||||||
document.addEventListener('visibilitychange', onVisChange);
|
document.addEventListener('visibilitychange', onVisChange);
|
||||||
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
|
|
||||||
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
|
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -3971,6 +4142,8 @@ function App() {
|
|||||||
// swap manifest so Android home-screen installs launch /display
|
// swap manifest so Android home-screen installs launch /display
|
||||||
const link = document.querySelector('link[rel="manifest"]');
|
const link = document.querySelector('link[rel="manifest"]');
|
||||||
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
|
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
|
||||||
|
const favicon = document.querySelector('link[rel="icon"]');
|
||||||
|
if (favicon) favicon.href = `${process.env.PUBLIC_URL || ''}/player-favicon.ico`;
|
||||||
}
|
}
|
||||||
if (window.location.pathname === '/logs') {
|
if (window.location.pathname === '/logs') {
|
||||||
setIsLogsMode(true);
|
setIsLogsMode(true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
|
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
|
||||||
// Safe default: any value other than exactly '1' = off.
|
// Safe default: any value other than exactly '1' = off.
|
||||||
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
|
// Dynamic key access (process.env[KEY]) so react-scripts DefinePlugin does NOT
|
||||||
// at build time — allows tests + runtime env changes to take effect.
|
// inline the value at build time — allows tests + runtime env changes to take
|
||||||
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
|
// effect. DefinePlugin only replaces static member access, never computed lookups.
|
||||||
|
const KEY = 'REACT_APP_DEV_TOOLS';
|
||||||
export const isDevToolsEnabled = () => process.env[KEY] === '1';
|
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', () => {
|
describe('DisplayView characterization', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
const favicon = document.createElement('link');
|
||||||
|
favicon.rel = 'icon';
|
||||||
|
favicon.href = '/favicon.ico';
|
||||||
|
document.head.appendChild(favicon);
|
||||||
window.history.replaceState({}, '', '/display');
|
window.history.replaceState({}, '', '/display');
|
||||||
global.alert = jest.fn();
|
global.alert = jest.fn();
|
||||||
window.open = jest.fn();
|
window.open = jest.fn();
|
||||||
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
|
|||||||
Element.prototype.scrollIntoView = jest.fn();
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
|
||||||
window.history.replaceState({}, '', '/');
|
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 () => {
|
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||||
seedActiveDisplay();
|
seedActiveDisplay();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
@@ -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 () => {
|
test('toggleActive: updateDoc flips isActive', async () => {
|
||||||
await setupReady();
|
await setupReady();
|
||||||
await addMonsterViaUI('Toggle', 10, 0);
|
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);
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
|
||||||
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
|
|||||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||||
await setupReady();
|
await setupReady();
|
||||||
await addMonsterViaUI('Cond', 10, 0);
|
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 }));
|
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
||||||
await waitFor(() => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
|
|||||||
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||||
|
|
||||||
// open edit modal for Goblin, bump init to 8
|
// 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'));
|
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
|
||||||
fireEvent.click(goblinEdit);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user