Author SHA1 Message Date
david raistrick b5b339a5dd feat: temp HP, character isNpc, initiative box
Temp HP:
- setTempHp() in shared/turn.js (replaces, no stacking)
- Damage absorbs temp HP first (both 5e + generic rulesets)
- Inline temp HP input on participant card (cyan when active)
- Temp HP field in edit modal
- makeParticipant tempHp field (default 0)
- 8 shared tests

Character isNpc:
- isNpc field on character roster model
- buildCharacterParticipant: type 'npc' when isNpc
- Add form checkbox + edit form checkbox
- NPC badge on character list row
- Writeback preserves isNpc

Initiative box:
- Amber-bordered box around initiative input
- Gold text (amber-300), disabled stays gold
- Stone-950 bg matches page
- Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
2026-07-08 21:36:26 -04:00
david raistrick eef11c3b6e fix: scope selectedEncounter + scrollY localStorage per campaign
- selectedEncounter key: ttrpg.selectedEncounter.{campaignId}
- scrollY key: ttrpg.scrollY.{campaignId}
- Restore scoped encounter selection on campaign switch
- Global keys unchanged (selectedCampaign, wakeLock, collapses)
- Prevents same-browser tabs on different campaigns from fighting
- Tests: 3 scoping cases (scoped encounter, scoped scroll, global intact)
2026-07-08 20:00:13 -04:00
david raistrick 2c6dfdafc8 fix: guard player display from cross-encounter conflicts
- Start encounter only claims display if slot empty or already ours
  (prevents stealing display from another live encounter)
- End encounter only clears display if THIS encounter is the one showing
  (prevents killing display for a different live encounter)
- Use unwrapped activeDisplayData (not snapshot wrapper activeDisplayInfo)
- Tests: 4 display guard cases (claim empty, no-steal busy, clear own,
  no-clear other)
2026-07-08 19:56:00 -04:00
david raistrick c05a283cf0 feat: encounter card 'IN PROGRESS' badge + red ring for active combat
- Pulsing red ⚔ IN PROGRESS badge on encounter card title
- Red ring around card when combat started and not ended
2026-07-08 19:41:35 -04:00
david raistrick 69ab0c35c0 fix: move revive/mark dead to stats row, bump stat font size
- Revive/Mark Dead buttons moved to init/hp/maxhp row (right justified)
- Separate from damage/heal controls
- Stat inputs bumped text-sm -> text-base, widths increased
2026-07-08 19:39:32 -04:00
david raistrick 91e23856b4 feat: tablet-friendly encounter layout
- Combat controls always left column (narrow portrait, wide desktop)
- Short labels below lg breakpoint, full labels at lg+
- Sticky controls all viewports
- Collapsible Add Participants section (auto-collapse during combat)
- Fullscreen encounter toggle
- Modals via createPortal to escape stacking context
- Reduced padding/margins in portrait orientation
2026-07-08 19:28:05 -04:00
david raistrick fd03ae48c8 Character writeback on encounter end
Campaign-level toggle (CharacterManager, live flip). When ON, ending an
encounter writes each character participant's current HP, Max HP, and AC back
to the campaign roster so they carry into the next encounter.

- shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp)
- endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters,
  snapshots old values into undo payload for restore. Works retroactive
  (ctx.campaignId fallback for encounters lacking campaignId field).
- New encounters store campaignId field.
- Undo end-encounter restores character values client-side.
- CharacterManager: styled sync toggle (checkbox badge), stat badges in list
  (HP/Current HP/AC/Init), edit box supports Current HP field.

Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char).
2026-07-08 16:33:21 -04:00
david raistrick 96770099f6 Persist campaign/encounter selection + scroll position across reload
UI reset to top on every reload/code update. Now:
- selectedCampaignId persisted via localStorage
- selectedEncounterId persisted via localStorage
- scrollY saved (beforeunload + pagehide + visibilitychange + 2s interval
  for Android Chrome reliability)
- scroll restored after campaigns data loads (300ms delay for mobile render)
2026-07-08 14:12:35 -04:00
david raistrick ebd91ca42a Inline editable fields (init/hp/maxhp/ac) with editing overlay
Participant card fields now editable in place (D-style): transparent bg,
underline on hover/focus, no spinners. Covers Initiative, Current HP, Max HP,
and AC badge value.

Click any value to edit. Blur/Enter saves. Escape cancels. Status recomputes
on HP change (conscious/dying/dead/down per ruleset).

Editing overlay: when a field is focused, amber ring highlights the card and
a centered pointer-events-none label shows '✎ Editing {field}'. Label floats
over card middle without blocking input — taps pass through to field.

Handlers: handleInlineCurrentHp (recomputes status), handleInlineMaxHp,
handleInlineAc. Keys prefixed to avoid React key collisions when values match.

Tests: selectors updated to use element id (monsterMaxHp) since inline
aria-labels now match form label queries.
2026-07-08 14:05:55 -04:00
10 changed files with 943 additions and 122 deletions
+7 -1
View File
@@ -11,7 +11,7 @@ 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!!!
^feature to add back to campaign character after encounter?
@@ -20,6 +20,12 @@ maybe campaign toggle in charc section (choosing each end is DM overload will be
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?
I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs.....
+102
View File
@@ -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);
});
});
+157
View File
@@ -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
View File
@@ -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,
};
+409 -91
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
import { createPortal } from 'react-dom';
import * as shared from '@ttrpg/shared';
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
import { isDevToolsEnabled } from './config/devTools';
@@ -35,7 +36,7 @@ function ToastStack({ toasts, onDismiss }) {
function InfoModal({ message, onClose }) {
if (!message) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex items-center mb-4">
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
@@ -346,8 +347,8 @@ function Modal({ onClose, title, children }) {
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
return createPortal(
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide">{title}</h2>
@@ -357,7 +358,8 @@ function Modal({ onClose, title, children }) {
</div>
{children}
</div>
</div>
</div>,
document.body
);
}
@@ -391,8 +393,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
return createPortal(
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-[100]">
<div className="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
<div className="flex items-center mb-4">
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
@@ -419,7 +421,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -500,6 +503,7 @@ function CreateCampaignForm({ onCreate, onCancel }) {
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
@@ -585,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
const [tempHp, setTempHp] = useState(participant.tempHp || 0);
const handleSubmit = (e) => {
e.preventDefault();
@@ -604,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
maxHp: finalMaxHp,
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : 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,
});
};
@@ -662,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/>
</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') && (
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
@@ -726,12 +746,13 @@ function EditParticipantModal({ participant, onClose, onSave }) {
// CHARACTER MANAGER COMPONENT
// ============================================================================
function CharacterManager({ campaignId, campaignCharacters }) {
function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
const { showToast, showInfo } = useUIFeedback();
const [characterName, setCharacterName] = useState('');
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
const [defaultAc, setDefaultAc] = useState('');
const [isCharacterNpc, setIsCharacterNpc] = useState(false);
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
@@ -778,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
name: characterName.trim(),
defaultMaxHp: hp,
defaultInitMod: initMod,
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null,
isNpc: isCharacterNpc || false,
};
try {
@@ -789,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters }) {
setDefaultMaxHp(DEFAULT_MAX_HP);
setDefaultInitMod(DEFAULT_INIT_MOD);
setDefaultAc('');
setIsCharacterNpc(false);
} catch (err) {
console.error("Error adding character:", err);
showToast("Failed to add character. Please try again."); }
};
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc) => {
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => {
if (!db || !newName.trim() || !campaignId) return;
const hp = parseInt(newDefaultMaxHp, 10);
@@ -811,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const updatedCharacters = campaignCharacters.map(c =>
c.id === characterId
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 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
);
@@ -845,7 +868,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
return (
<>
<div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="p-2 md:p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<button
onClick={() => setIsOpen(!isOpen)}
@@ -860,6 +883,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
{isOpen && (
<>
<label className={`inline-flex items-center gap-2 mb-3 px-3 py-1.5 rounded-lg cursor-pointer text-sm font-medium transition-colors ${syncCharacters ? 'bg-amber-900 border border-amber-500 text-amber-100' : 'bg-stone-800 border border-stone-700 text-stone-400 hover:text-stone-200'}`} title="When enabled, ending an encounter writes each character's current HP, Max HP, and AC back to the campaign roster so they carry into the next encounter."
>
<input
type="checkbox"
checked={syncCharacters}
onChange={(e) => storage.updateDoc(getPath.campaign(campaignId), { syncCharacters: e.target.checked })}
className="h-4 w-4 text-amber-600 border-stone-400 rounded focus:ring-amber-500"
/>
Sync character changes on encounter end
</label>
<form onSubmit={(e) => { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end">
<div className="col-span-2 sm:col-span-6">
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
@@ -910,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters }) {
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>
<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
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"
@@ -944,7 +986,9 @@ function CharacterManager({ campaignId, campaignCharacters }) {
editingCharacter.name,
editingCharacter.defaultMaxHp,
editingCharacter.defaultInitMod,
editingCharacter.defaultAc
editingCharacter.defaultAc,
editingCharacter.defaultCurrentHp,
editingCharacter.isNpc
);
}}
className="flex-grow flex flex-wrap gap-2 items-center"
@@ -968,6 +1012,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
title="Default Max HP"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-stone-400 mb-0.5">Cur HP</label>
<input
type="number"
value={editingCharacter.defaultCurrentHp ?? ''}
onChange={(e) => setEditingCharacter({ ...editingCharacter, defaultCurrentHp: e.target.value })}
className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white"
title="Default Current HP"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
<input
@@ -988,6 +1042,15 @@ function CharacterManager({ campaignId, campaignCharacters }) {
title="AC"
/>
</div>
<label className="flex items-center gap-1 text-[10px] text-stone-400" title="Long-lived NPC">
<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="p-1 text-green-400 hover:text-green-300">
<Save size={18} />
</button>
@@ -1001,20 +1064,31 @@ function CharacterManager({ campaignId, campaignCharacters }) {
</form>
) : (
<>
<span className="text-stone-100">
{character.name}{' '}
<span className="text-xs text-stone-400">
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
</span>
</span>
<div className="flex space-x-2">
<div className="flex items-center mr-auto">
<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">
<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 && (
<span className="px-1.5 py-0.5 rounded bg-red-950 text-red-300 text-xs font-medium" title="Current HP (carried from last encounter)">Current HP {character.defaultCurrentHp}</span>
)}
{character.defaultAc != null && (
<span className="px-1.5 py-0.5 rounded bg-sky-900/60 text-sky-200 text-xs font-medium">AC {character.defaultAc}</span>
)}
<span className="px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-200 text-xs font-medium">Init {formatInitMod(character.defaultInitMod)}</span>
</div>
</div>
<div className="flex space-x-2 flex-shrink-0">
<button
onClick={() => setEditingCharacter({
id: character.id,
name: character.name,
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD,
defaultAc: character.defaultAc ?? ''
defaultAc: character.defaultAc ?? '',
defaultCurrentHp: character.defaultCurrentHp ?? ''
})}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
aria-label="Edit character"
@@ -1065,6 +1139,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const [manualInitiative, setManualInitiative] = useState('');
const [monsterAc, setMonsterAc] = useState('');
const [asNpc, setAsNpc] = useState(false);
const combatActive = encounter.isStarted && !encounter.isPaused;
const [addSectionOpen, setAddSectionOpen] = useState(!combatActive);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
@@ -1085,6 +1161,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
];
const participants = encounter.participants || [];
const [editingField, setEditingField] = useState(null);
const [editingId, setEditingId] = useState(null);
const blurTimeoutRef = useRef(null);
const handleFieldFocus = (id, label) => {
if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; }
setEditingId(id);
setEditingField(label);
};
const handleFieldBlur = () => {
blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150);
};
useEffect(() => {
if (participantType === 'character' && selectedCharacterId) {
@@ -1283,6 +1370,62 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleInlineCurrentHp = async (participantId, value) => {
let n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.currentHp) return;
try {
const isGeneric = (encounter.ruleset === 'generic');
let patch;
if (!isGeneric && n < 0) n = 0;
if (isGeneric) {
patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' };
} else {
patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 };
if (n > 0) { patch.status = 'conscious'; }
else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; }
else { patch.status = 'dying'; }
}
await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update HP.');
}
};
const handleInlineMaxHp = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.maxHp || n <= 0) return;
try {
await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update Max HP.');
}
};
const handleInlineAc = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.ac) return;
try {
await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update AC.');
}
};
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) => {
if (!db) return;
try {
@@ -1401,12 +1544,25 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
return (
<>
<div className="p-3 bg-stone-900 rounded-md mt-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-lg font-medium text-amber-200 font-cinzel tracking-wide">Add Participants</h4>
{(() => {
const open = addSectionOpen && !combatActive;
return (
<details open={open} className="mt-2">
<summary
onClick={(e) => { if (!combatActive) { e.preventDefault(); setAddSectionOpen(v => !v); } else { e.preventDefault(); } }}
className="cursor-pointer text-lg font-medium text-amber-200 font-cinzel tracking-wide flex items-center gap-2 select-none"
>
{open ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
Add Participants
{combatActive && <span className="text-xs text-stone-400 font-normal ml-2">(pause to add)</span>}
</summary>
{open && (
<>
<div className="flex justify-end items-center mb-3 mt-2">
<button
onClick={handleAddAllCampaignCharacters}
className="px-3 py-1.5 text-xs font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors flex items-center"
disabled={!campaignCharacters || campaignCharacters.length === 0 || (encounter.isStarted && !encounter.isPaused)}
disabled={!campaignCharacters || campaignCharacters.length === 0 || combatActive}
>
<Users2 size={16} className="mr-1.5" />
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
@@ -1590,18 +1746,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</button>
</div>
</form>
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
</>
)}
</details>
);
})()}
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
<ul className="space-y-2">
{sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
@@ -1628,8 +1781,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`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' : ''}`}
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' : ''}`}
>
{editingId === p.id && (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="px-4 py-1 bg-amber-600 rounded-full text-white text-sm font-bold shadow-lg">
Editing {editingField}
</span>
</span>
)}
<div className="flex-1 flex items-start sm:items-center">
{isDraggable && (
<ChevronsUpDown
@@ -1645,7 +1805,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{p.ac != null && (
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span>
<input
type="number"
defaultValue={p.ac}
key={`ac-${p.ac}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'AC'); }}
onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }}
className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`AC for ${p.name}`}
/>
</span>
)}
{isCurrentTurn && !encounter.isPaused && (
@@ -1659,38 +1828,74 @@ 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>}
</p>
<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>
<input
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
key={`init-${p.initiative}`}
min="0"
max="99"
disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
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(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
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}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
<span className="inline-flex items-baseline">
<input
type="number"
id={`hp-${p.id}`}
defaultValue={p.currentHp}
key={`hp-${p.currentHp}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }}
className="w-10 bg-transparent text-white 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"
aria-label={`Current HP for ${p.name}`}
/>
<span className="text-stone-500 text-base">/</span>
<input
type="number"
defaultValue={p.maxHp}
key={`maxhp-${p.maxHp}`}
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(); }}
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-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}`}
/>
</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">
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
<HeartPulse size={14} /> Revive
<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)">
<HeartPulse size={12} /> Revive
</button>
)}
{isGeneric && !isDead && (
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
<HeartCrack size={14} /> Mark Dead
<button onClick={() => handleMarkDead(p.id)} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
<HeartCrack size={12} /> Mark Dead
</button>
)}
</span>
</div>
{hasDeathSaves && participantStatus === 'stable' && (
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div>
@@ -1889,6 +2094,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
})}
</ul>
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
)}
{editingParticipant && (
<EditParticipantModal
participant={editingParticipant}
@@ -1948,6 +2163,21 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
: getUndo(undoTarget); // legacy
if (!expanded) return;
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
// Restore character writeback (end_encounter sync)
const cw = undoTarget.undo?.characterWriteback;
const wbId = encounter?.campaignId || campaignId;
if (cw && wbId) {
try {
const campDoc = await storage.getDoc(getPath.campaign(wbId));
if (campDoc && Array.isArray(campDoc.players)) {
const restored = campDoc.players.map(p => {
const old = cw.find(c => c.id === p.id);
return old ? { ...p, defaultMaxHp: old.defaultMaxHp, defaultAc: old.defaultAc, defaultCurrentHp: old.defaultCurrentHp } : p;
});
await storage.updateDoc(getPath.campaign(wbId), { players: restored });
}
} catch (e) { console.error('characterWriteback restore failed:', e); }
}
}
} catch (err) {
console.error('Error undoing action:', err);
@@ -1998,7 +2228,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
await startEncounter(encounter, buildCtx(encounterPath));
// 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) {
showToast(err.message || "Failed to start encounter. Please try again."); }
};
@@ -2033,8 +2269,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const confirmEndEncounter = async () => {
if (!db) return;
try {
await endEncounter(encounter, buildCtx(encounterPath));
const ctx = { ...buildCtx(encounterPath), campaignId };
if (campaignId) {
try {
ctx.campaign = await storage.getDoc(getPath.campaign(campaignId));
} catch {}
}
await endEncounter(encounter, ctx);
// 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) {
showToast("Failed to end encounter. Please try again."); }
@@ -2045,95 +2293,95 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
return (
<>
<div className="lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg">
<h4 className="text-lg font-medium text-amber-200 mb-4 text-center font-cinzel tracking-wide">Combat Controls</h4>
<div className="flex flex-col gap-3">
<div className="md:sticky md:top-2 p-3 bg-stone-900 rounded-md shadow-lg">
<h4 className="text-base font-medium text-amber-200 mb-2 text-center font-cinzel tracking-wide">Combat Controls</h4>
<div className="flex flex-col gap-2">
{!encounter.isStarted ? (
<button
onClick={handleStartEncounter}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-red-700 hover:bg-red-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.participants || encounter.participants.filter(p => p.isActive).length === 0}
>
<PlayIcon size={18} className="mr-2" /> Start Combat
<PlayIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">Start Combat</span><span className="inline lg:hidden truncate">Start</span>
</button>
) : (
<>
<button
onClick={handleTogglePause}
className={`w-full px-4 py-3 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${encounter.isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700'}`}
className={`w-full min-w-0 px-3 py-2 text-sm font-medium text-white rounded-md transition-colors flex items-center justify-center ${encounter.isPaused ? 'bg-red-700 hover:bg-red-800' : 'bg-amber-600 hover:bg-amber-700'}`}
title={encounter.isPaused
? 'Resume combat from the current turn. The initiative order will be recalculated to include any participants added while paused.'
: 'Pause combat to freeze the turn order. While paused, you can add or remove participants, adjust HP, and edit initiative values. The turn order will be recalculated when you resume.'}
>
{encounter.isPaused ? <PlayIcon size={18} className="mr-2" /> : <PauseIcon size={18} className="mr-2" />}
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
{encounter.isPaused ? <PlayIcon size={16} className="mr-1 flex-shrink-0" /> : <PauseIcon size={16} className="mr-1 flex-shrink-0" />}
<span className="truncate hidden lg:inline">{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}</span><span className="inline lg:hidden truncate">{encounter.isPaused ? 'Resume' : 'Pause'}</span>
</button>
<button
onClick={handleNextTurn}
className="w-full px-4 py-3 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-purple-700 hover:bg-purple-800 rounded-md transition-colors flex items-center justify-center"
disabled={!encounter.currentTurnParticipantId || encounter.isPaused}
>
<SkipForwardIcon size={18} className="mr-2" /> Next Turn
<SkipForwardIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">Next Turn</span><span className="inline lg:hidden truncate">Next</span>
</button>
<button
onClick={() => setShowEndConfirm(true)}
className="w-full px-4 py-3 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
className="w-full min-w-0 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors flex items-center justify-center"
>
<StopCircleIcon size={18} className="mr-2" /> End Combat
<StopCircleIcon size={16} className="mr-1 flex-shrink-0" /> <span className="truncate hidden lg:inline">End Combat</span><span className="inline lg:hidden truncate">End</span>
</button>
{/* Round Counter */}
<div className="mt-2 pt-3 border-t border-stone-700">
<p className="text-center text-lg font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
<div className="mt-1 pt-2 border-t border-stone-700">
<p className="text-center text-base font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
{encounter.isPaused && (
<p className="text-center text-sm text-yellow-400 font-semibold mt-1">(Paused)</p>
<p className="text-center text-xs text-yellow-400 font-semibold mt-1">(Paused)</p>
)}
</div>
</>
)}
</div>
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
<div className="mt-3 flex gap-2">
{/* Undo / Redo */}
<div className="mt-2 flex gap-2">
<button
onClick={handleUndo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-amber-700 hover:bg-amber-600 rounded-md transition-colors flex items-center justify-center"
title="Undo latest action for this encounter"
>
<Undo2 size={14} className="mr-1" /> Undo
<Undo2 size={12} className="mr-1" /> <span className="lg:hidden">Un</span><span className="hidden lg:inline">Undo</span>
</button>
<button
onClick={handleRedo}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-sky-700 hover:bg-sky-600 rounded-md transition-colors flex items-center justify-center"
title="Redo latest undone action for this encounter"
>
<Redo2 size={14} className="mr-1" /> Redo
<Redo2 size={12} className="mr-1" /> <span className="lg:hidden">Re</span><span className="hidden lg:inline">Redo</span>
</button>
</div>
{/* Display Settings */}
<div className="mt-3 pt-3 border-t border-stone-700">
<div className="mt-2 pt-2 border-t border-stone-700">
<h5 className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2">Player Display</h5>
<label className="flex items-center justify-between cursor-pointer gap-2">
<span className="text-sm text-stone-300">Hide player HP</span>
<span className="text-xs text-stone-300">Hide player HP</span>
<button
role="switch"
aria-checked={hidePlayerHp}
onClick={handleToggleHidePlayerHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600'}`}
className={`relative inline-flex h-4 w-8 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hidePlayerHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
<span className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
<span className="text-sm text-stone-300">Hide NPC/monster HP</span>
<span className="text-xs text-stone-300">Hide NPC/monster HP</span>
<button
role="switch"
aria-checked={hideNpcHp}
onClick={handleToggleHideNpcHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
className={`relative inline-flex h-4 w-8 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
<span className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
@@ -2163,14 +2411,29 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; }
catch { return null; }
});
const [showCreateModal, setShowCreateModal] = useState(false);
const [encounterFullscreen, setEncounterFullscreen] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
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(() => {
if (encountersData) setEncounters(encountersData);
}, [encountersData]);
@@ -2216,7 +2479,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
try {
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
} catch {}
}, [selectedEncounterId, campaignId]);
useEffect(() => {
if (!campaignId) {
@@ -2251,6 +2518,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
try {
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
name: name.trim(),
campaignId,
createdAt: new Date().toISOString(),
participants: [],
round: 0,
@@ -2347,7 +2615,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
return (
<>
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow">
<div className="mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Swords size={24} className="mr-2" /> Encounters
@@ -2378,11 +2646,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
onDragOver={handleEncounterDragOver}
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
onDragEnd={() => setDraggedEncounterId(null)}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${encounter.isStarted && !encounter.endedAt ? 'ring-2 ring-red-500' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
<h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span>{encounter.isStarted && !encounter.endedAt && <span className="ml-1 px-1.5 py-0.5 rounded text-xs font-bold bg-red-600 text-white animate-pulse"> IN PROGRESS</span>}</h4>
<p className="text-xs text-stone-300">
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
</p>
@@ -2439,13 +2707,20 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
{selectedEncounter && (
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow-inner">
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide">
Managing Encounter: {selectedEncounter.name}
<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">
<span>Managing Encounter: {selectedEncounter.name}</span>
<button
onClick={() => setEncounterFullscreen(v => !v)}
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0"
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'}
>
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
</h3>
<div className="flex flex-col lg:flex-row gap-4">
{/* Combat Controls - Left Side (Sticky on large screens) */}
<div className="lg:w-64 flex-shrink-0">
<div className="flex flex-row gap-4">
{/* Combat Controls - always left */}
<div className="w-32 md:w-64 flex-shrink-0 sticky top-4 self-start">
<InitiativeControls
campaignId={campaignId}
encounter={selectedEncounter}
@@ -2538,11 +2813,53 @@ function AdminView({ userId }) {
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(() => {
try { return localStorage.getItem('ttrpg.selectedCampaign') || null; }
catch { return null; }
});
const encounterStartedRef = useRef(false);
const encounterActiveRef = useRef(false);
const manualSelectRef = useRef(false);
const prevDisplayCampaignRef = useRef(null);
const scrollRestoredRef = useRef(false);
// Save scroll on unload + visibility change + interval.
useEffect(() => {
const onSave = () => {
try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {}
};
window.addEventListener('beforeunload', onSave);
window.addEventListener('pagehide', onSave);
document.addEventListener('visibilitychange', onSave);
const interval = setInterval(onSave, 2000);
return () => {
window.removeEventListener('beforeunload', onSave);
window.removeEventListener('pagehide', onSave);
document.removeEventListener('visibilitychange', onSave);
clearInterval(interval);
};
}, [selectedCampaignId]);
// Restore scroll once data loaded.
useEffect(() => {
if (scrollRestoredRef.current) return;
if (campaignsWithDetails.length === 0) return;
try {
const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10);
if (y > 0) {
scrollRestoredRef.current = true;
setTimeout(() => window.scrollTo(0, y), 300);
}
} catch {}
}, [campaignsWithDetails, selectedCampaignId]);
// Persist selections across reload.
useEffect(() => {
try {
if (selectedCampaignId) localStorage.setItem('ttrpg.selectedCampaign', selectedCampaignId);
else localStorage.removeItem('ttrpg.selectedCampaign');
} catch {}
}, [selectedCampaignId]);
// External display change (replay/other DM) = clear manual override, allow follow.
useEffect(() => {
@@ -2761,7 +3078,7 @@ function AdminView({ userId }) {
return (
<>
<div className="space-y-6">
<div className="space-y-3 md:space-y-6">
<div>
<div className="flex justify-between items-center mb-4">
<button
@@ -2913,13 +3230,14 @@ function AdminView({ userId }) {
)}
{selectedCampaign && (
<div className="mt-6 p-6 bg-stone-900 rounded-lg shadow-xl">
<div className="mt-3 md:mt-6 p-3 md:p-6 bg-stone-900 rounded-lg shadow-xl">
<h2 className="text-2xl font-semibold text-amber-300 mb-4 font-cinzel tracking-wide">
Managing: {selectedCampaign.name}
</h2>
<CharacterManager
campaignId={selectedCampaignId}
campaignCharacters={selectedCampaign.characters || []}
syncCharacters={!!selectedCampaign.syncCharacters}
/>
<hr className="my-6 border-stone-700" />
<EncounterManager
@@ -3639,7 +3957,7 @@ function LogsView() {
</div>
</header>
<main className="container mx-auto p-4 md:p-8">
<main className="mx-auto p-4 md:p-8 max-w-7xl">
{isLoading ? (
<LoadingSpinner message="Loading logs..." />
) : logs.length === 0 ? (
+94
View File
@@ -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);
});
});
+1 -1
View File
@@ -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(() => {
+39
View File
@@ -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();
});
});
+1 -1
View File
@@ -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(() => {
+1 -1
View File
@@ -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(() => {