Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 | ||
|
|
c05a283cf0 | ||
|
|
69ab0c35c0 | ||
|
|
91e23856b4 | ||
|
|
fd03ae48c8 | ||
|
|
96770099f6 | ||
|
|
ebd91ca42a |
@@ -11,7 +11,7 @@ also better vert tab layout - labelt friendly
|
|||||||
|
|
||||||
x needs AC for players dude
|
x needs AC for players dude
|
||||||
|
|
||||||
and quick entry hp
|
x and quick entry hp
|
||||||
|
|
||||||
hp do not carry from encounter to ecnounter!!!
|
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?
|
||||||
@@ -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
|
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.....
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Temp HP: damage hits temp first, then regular.
|
||||||
|
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function setupEnc() {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
return {
|
||||||
|
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
|
||||||
|
ctx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('temp HP', () => {
|
||||||
|
test('setTempHp sets tempHp on participant', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTempHp replaces existing temp (no stacking)', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 3, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTempHp to 0 clears temp', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 0, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage: temp absorbs before regular HP', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(0);
|
||||||
|
expect(e.participants[0].currentHp).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage less than temp: only temp reduced', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 10, ctx);
|
||||||
|
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(6);
|
||||||
|
expect(e.participants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal: does not affect temp HP', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(5);
|
||||||
|
expect(e.participants[0].currentHp).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage exactly equals temp: temp gone, regular intact', async () => {
|
||||||
|
const { enc, ctx } = setupEnc();
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
let e = await addParticipant(enc, p, ctx);
|
||||||
|
e = await setTempHp(e, p.id, 5, ctx);
|
||||||
|
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
|
||||||
|
expect(e.participants[0].tempHp).toBe(0);
|
||||||
|
expect(e.participants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makeParticipant: tempHp defaults to 0', async () => {
|
||||||
|
const p = makeParticipant({
|
||||||
|
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||||
|
});
|
||||||
|
expect(p.tempHp).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { endEncounter } = shared;
|
||||||
|
|
||||||
|
function makeMockStorage() {
|
||||||
|
const docs = new Map();
|
||||||
|
const writes = [];
|
||||||
|
return {
|
||||||
|
getDoc: jest.fn(async (p) => docs.get(p) || null),
|
||||||
|
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
|
||||||
|
updateDoc: jest.fn(async (p, patch) => {
|
||||||
|
writes.push({ path: p, patch });
|
||||||
|
const cur = docs.get(p) || {};
|
||||||
|
docs.set(p, { ...cur, ...patch });
|
||||||
|
}),
|
||||||
|
addDoc: jest.fn(async (p, data) => {
|
||||||
|
writes.push({ path: p, patch: data });
|
||||||
|
docs.set(p, data);
|
||||||
|
}),
|
||||||
|
deleteDoc: jest.fn(async () => {}),
|
||||||
|
getCollection: jest.fn(async () => []),
|
||||||
|
_docs: docs,
|
||||||
|
_writes: writes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEncounter(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'enc1',
|
||||||
|
name: 'Test',
|
||||||
|
campaignId: 'camp1',
|
||||||
|
isStarted: true,
|
||||||
|
isPaused: false,
|
||||||
|
currentTurnParticipantId: 'p1',
|
||||||
|
round: 3,
|
||||||
|
turnOrderIds: ['p1', 'p2'],
|
||||||
|
participants: [
|
||||||
|
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
|
||||||
|
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
|
||||||
|
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('character writeback on endEncounter', () => {
|
||||||
|
test('no writeback when syncCharacters false/missing', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
storage._docs.set('campaigns/camp1', {
|
||||||
|
id: 'camp1', name: 'Camp', players: [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
// no campaign write
|
||||||
|
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||||
|
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||||
|
];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||||
|
expect(campWrite).toBeDefined();
|
||||||
|
const updated = campWrite.patch.players;
|
||||||
|
const fighter = updated.find(p => p.id === 'char1');
|
||||||
|
expect(fighter.defaultMaxHp).toBe(28);
|
||||||
|
expect(fighter.defaultAc).toBe(18);
|
||||||
|
expect(fighter.defaultCurrentHp).toBe(20);
|
||||||
|
const cleric = updated.find(p => p.id === 'char2');
|
||||||
|
expect(cleric.defaultMaxHp).toBe(22);
|
||||||
|
expect(cleric.defaultAc).toBe(16);
|
||||||
|
expect(cleric.defaultCurrentHp).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback skips monsters (no originalCharacterId)', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||||
|
// players array still 1 entry, goblin not added
|
||||||
|
expect(campWrite.patch.players.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo payload includes old char values for restore', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||||
|
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||||
|
];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
storage._docs.set('logs/log1', null);
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
|
||||||
|
expect(logWrite).toBeDefined();
|
||||||
|
const log = logWrite.patch;
|
||||||
|
expect(log.undo.characterWriteback).toBeDefined();
|
||||||
|
expect(log.undo.characterWriteback).toHaveLength(2);
|
||||||
|
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
|
||||||
|
expect(fighterOld.defaultMaxHp).toBe(30);
|
||||||
|
expect(fighterOld.defaultAc).toBe(17);
|
||||||
|
expect(fighterOld.defaultCurrentHp).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback no-op if character missing from campaign', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
// no writeback (no matching chars = no change)
|
||||||
|
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
-11
@@ -362,6 +362,7 @@ function makeParticipant(opts) {
|
|||||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,14 +371,15 @@ function buildCharacterParticipant(character) {
|
|||||||
const modifier = character.defaultInitMod || 0;
|
const modifier = character.defaultInitMod || 0;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
||||||
|
const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
|
||||||
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,
|
||||||
currentHp: maxHp,
|
currentHp,
|
||||||
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||||
@@ -622,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 || {}) : {};
|
||||||
@@ -636,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,
|
||||||
@@ -647,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.
|
||||||
@@ -655,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) {
|
||||||
@@ -686,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 };
|
||||||
@@ -746,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 =>
|
||||||
@@ -771,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 };
|
||||||
@@ -1054,6 +1127,38 @@ async function endEncounter(encounter, ctx) {
|
|||||||
delta: {},
|
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 },
|
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);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,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,
|
||||||
};
|
};
|
||||||
|
|||||||
+425
-107
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import * as shared from '@ttrpg/shared';
|
import * as shared from '@ttrpg/shared';
|
||||||
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
|
import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage';
|
||||||
import { isDevToolsEnabled } from './config/devTools';
|
import { isDevToolsEnabled } from './config/devTools';
|
||||||
@@ -35,7 +36,7 @@ function ToastStack({ toasts, onDismiss }) {
|
|||||||
function InfoModal({ message, onClose }) {
|
function InfoModal({ message, onClose }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
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="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
|
<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);
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<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="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide">{title}</h2>
|
<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>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,8 +393,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<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="bg-stone-900 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
|
<AlertTriangle size={24} className="text-yellow-400 mr-3 flex-shrink-0" />
|
||||||
@@ -419,7 +421,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +503,7 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -585,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();
|
||||||
@@ -604,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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -662,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>
|
||||||
@@ -726,12 +746,13 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
|||||||
// CHARACTER MANAGER COMPONENT
|
// CHARACTER MANAGER COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function CharacterManager({ campaignId, campaignCharacters }) {
|
function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||||
const { showToast, showInfo } = useUIFeedback();
|
const { showToast, showInfo } = useUIFeedback();
|
||||||
const [characterName, setCharacterName] = useState('');
|
const [characterName, setCharacterName] = useState('');
|
||||||
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);
|
||||||
@@ -778,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
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 {
|
||||||
@@ -789,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
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) => {
|
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);
|
||||||
@@ -811,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
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 }
|
? { ...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
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -845,7 +868,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
@@ -860,6 +883,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
|
|
||||||
{isOpen && (
|
{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">
|
<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">
|
<div className="col-span-2 sm:col-span-6">
|
||||||
<label htmlFor="characterName" className="block text-xs font-medium text-stone-400">
|
<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"
|
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"
|
||||||
@@ -944,7 +986,9 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
editingCharacter.name,
|
editingCharacter.name,
|
||||||
editingCharacter.defaultMaxHp,
|
editingCharacter.defaultMaxHp,
|
||||||
editingCharacter.defaultInitMod,
|
editingCharacter.defaultInitMod,
|
||||||
editingCharacter.defaultAc
|
editingCharacter.defaultAc,
|
||||||
|
editingCharacter.defaultCurrentHp,
|
||||||
|
editingCharacter.isNpc
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||||
@@ -968,6 +1012,16 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
title="Default Max HP"
|
title="Default Max HP"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col">
|
||||||
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
|
<label className="text-[10px] text-stone-400 mb-0.5">Init</label>
|
||||||
<input
|
<input
|
||||||
@@ -988,6 +1042,15 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
title="AC"
|
title="AC"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1001,20 +1064,31 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-stone-100">
|
<div className="flex items-center mr-auto">
|
||||||
{character.name}{' '}
|
<span className="text-stone-100 mr-2">{character.name}</span>
|
||||||
<span className="text-xs text-stone-400">
|
{character.isNpc && (
|
||||||
(HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
|
<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>
|
||||||
</span>
|
)}
|
||||||
</span>
|
<div className="flex gap-1">
|
||||||
<div className="flex space-x-2">
|
<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
|
<button
|
||||||
onClick={() => setEditingCharacter({
|
onClick={() => setEditingCharacter({
|
||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP,
|
||||||
defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD,
|
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"
|
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
|
||||||
aria-label="Edit character"
|
aria-label="Edit character"
|
||||||
@@ -1065,6 +1139,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
const [manualInitiative, setManualInitiative] = useState('');
|
const [manualInitiative, setManualInitiative] = useState('');
|
||||||
const [monsterAc, setMonsterAc] = useState('');
|
const [monsterAc, setMonsterAc] = useState('');
|
||||||
const [asNpc, setAsNpc] = useState(false);
|
const [asNpc, setAsNpc] = useState(false);
|
||||||
|
const combatActive = encounter.isStarted && !encounter.isPaused;
|
||||||
|
const [addSectionOpen, setAddSectionOpen] = useState(!combatActive);
|
||||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||||
const [draggedItemId, setDraggedItemId] = useState(null);
|
const [draggedItemId, setDraggedItemId] = useState(null);
|
||||||
@@ -1085,6 +1161,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
];
|
];
|
||||||
|
|
||||||
const participants = encounter.participants || [];
|
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(() => {
|
useEffect(() => {
|
||||||
if (participantType === 'character' && selectedCharacterId) {
|
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) => {
|
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
@@ -1401,17 +1544,30 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-3 bg-stone-900 rounded-md mt-4">
|
<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;
|
||||||
<button
|
return (
|
||||||
onClick={handleAddAllCampaignCharacters}
|
<details open={open} className="mt-2">
|
||||||
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"
|
<summary
|
||||||
disabled={!campaignCharacters || campaignCharacters.length === 0 || (encounter.isStarted && !encounter.isPaused)}
|
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"
|
||||||
<Users2 size={16} className="mr-1.5" />
|
>
|
||||||
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
|
{open ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||||
</button>
|
Add Participants
|
||||||
</div>
|
{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 || combatActive}
|
||||||
|
>
|
||||||
|
<Users2 size={16} className="mr-1.5" />
|
||||||
|
<Dices size={16} className="mr-1.5" /> Add All (Roll Init)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Warning when combat is active */}
|
{/* Warning when combat is active */}
|
||||||
{encounter.isStarted && !encounter.isPaused && (
|
{encounter.isStarted && !encounter.isPaused && (
|
||||||
@@ -1590,18 +1746,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
{lastRollDetails && (
|
)}
|
||||||
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
|
</details>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
|
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
|
||||||
|
|
||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sortedParticipants.map((p) => {
|
{sortedParticipants.map((p) => {
|
||||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||||
@@ -1628,8 +1781,15 @@ 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={`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">
|
<div className="flex-1 flex items-start sm:items-center">
|
||||||
{isDraggable && (
|
{isDraggable && (
|
||||||
<ChevronsUpDown
|
<ChevronsUpDown
|
||||||
@@ -1645,7 +1805,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
|||||||
{p.ac != null && (
|
{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="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-[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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCurrentTurn && !encounter.isPaused && (
|
{isCurrentTurn && !encounter.isPaused && (
|
||||||
@@ -1659,39 +1828,75 @@ 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"
|
||||||
id={`init-${p.id}`}
|
id={`init-${p.id}`}
|
||||||
defaultValue={p.initiative}
|
defaultValue={p.initiative}
|
||||||
key={p.initiative}
|
key={`init-${p.initiative}`}
|
||||||
min="0"
|
min="0"
|
||||||
max="99"
|
max="99"
|
||||||
disabled={encounter.isStarted && !encounter.isPaused}
|
disabled={encounter.isStarted && !encounter.isPaused}
|
||||||
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
|
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
||||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
|
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-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}`}
|
aria-label={`Initiative for ${p.name}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>HP: {p.currentHp}/{p.maxHp}</span>
|
<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="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="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>
|
</div>
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasDeathSaves && participantStatus === 'stable' && (
|
{hasDeathSaves && participantStatus === 'stable' && (
|
||||||
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable — regains 1 HP after 1d4 hours</div>
|
<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>
|
</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 && (
|
{editingParticipant && (
|
||||||
<EditParticipantModal
|
<EditParticipantModal
|
||||||
participant={editingParticipant}
|
participant={editingParticipant}
|
||||||
@@ -1948,6 +2163,21 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
: getUndo(undoTarget); // legacy
|
: getUndo(undoTarget); // legacy
|
||||||
if (!expanded) return;
|
if (!expanded) return;
|
||||||
await storage.undo({ logPath: `${getPath.logs()}/${undoTarget.id}`, undo: expanded });
|
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) {
|
} catch (err) {
|
||||||
console.error('Error undoing action:', err);
|
console.error('Error undoing action:', err);
|
||||||
@@ -1998,7 +2228,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."); }
|
||||||
};
|
};
|
||||||
@@ -2033,8 +2269,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
const confirmEndEncounter = async () => {
|
const confirmEndEncounter = async () => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
await endEncounter(encounter, buildCtx(encounterPath));
|
const ctx = { ...buildCtx(encounterPath), campaignId };
|
||||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
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) {
|
} catch (err) {
|
||||||
showToast("Failed to end encounter. Please try again."); }
|
showToast("Failed to end encounter. Please try again."); }
|
||||||
|
|
||||||
@@ -2045,95 +2293,95 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="lg:sticky lg:top-4 p-4 bg-stone-900 rounded-md shadow-lg">
|
<div className="md:sticky md:top-2 p-3 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>
|
<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-3">
|
<div className="flex flex-col gap-2">
|
||||||
{!encounter.isStarted ? (
|
{!encounter.isStarted ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleStartEncounter}
|
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}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleTogglePause}
|
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
|
title={encounter.isPaused
|
||||||
? 'Resume combat from the current turn. The initiative order will be recalculated to include any participants added while paused.'
|
? '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.'}
|
: '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 ? <PlayIcon size={16} className="mr-1 flex-shrink-0" /> : <PauseIcon size={16} className="mr-1 flex-shrink-0" />}
|
||||||
{encounter.isPaused ? 'Resume Combat' : 'Pause Combat'}
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={handleNextTurn}
|
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}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEndConfirm(true)}
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Round Counter */}
|
{/* Round Counter */}
|
||||||
<div className="mt-2 pt-3 border-t border-stone-700">
|
<div className="mt-1 pt-2 border-t border-stone-700">
|
||||||
<p className="text-center text-lg font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
|
<p className="text-center text-base font-semibold text-amber-300 font-cinzel">Round: {encounter.round}</p>
|
||||||
{encounter.isPaused && (
|
{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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Undo / Redo — queried on click (no live logs subscription during combat) */}
|
{/* Undo / Redo */}
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleUndo}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleRedo}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Settings */}
|
{/* 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>
|
<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">
|
<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
|
<button
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={hidePlayerHp}
|
aria-checked={hidePlayerHp}
|
||||||
onClick={handleToggleHidePlayerHp}
|
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>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
|
<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
|
<button
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={hideNpcHp}
|
aria-checked={hideNpcHp}
|
||||||
onClick={handleToggleHideNpcHp}
|
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>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2163,14 +2411,29 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||||||
|
|
||||||
const [encounters, setEncounters] = useState([]);
|
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 [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [encounterFullscreen, setEncounterFullscreen] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
|
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
|
||||||
|
|
||||||
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]);
|
||||||
@@ -2216,7 +2479,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedEncounterIdRef.current = selectedEncounterId;
|
selectedEncounterIdRef.current = selectedEncounterId;
|
||||||
}, [selectedEncounterId]);
|
try {
|
||||||
|
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
|
||||||
|
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
|
||||||
|
} catch {}
|
||||||
|
}, [selectedEncounterId, campaignId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!campaignId) {
|
if (!campaignId) {
|
||||||
@@ -2251,6 +2518,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
try {
|
try {
|
||||||
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
|
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
campaignId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
participants: [],
|
participants: [],
|
||||||
round: 0,
|
round: 0,
|
||||||
@@ -2347,7 +2615,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
return (
|
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">
|
<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">
|
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
|
||||||
<Swords size={24} className="mr-2" /> Encounters
|
<Swords size={24} className="mr-2" /> Encounters
|
||||||
@@ -2378,11 +2646,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
onDragOver={handleEncounterDragOver}
|
onDragOver={handleEncounterDragOver}
|
||||||
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
|
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
|
||||||
onDragEnd={() => setDraggedEncounterId(null)}
|
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 className="flex justify-between items-center">
|
||||||
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
<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">
|
<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}
|
{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>
|
</p>
|
||||||
@@ -2439,13 +2707,20 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedEncounter && (
|
{selectedEncounter && (
|
||||||
<div className="mt-6 p-4 bg-stone-900 rounded-lg shadow-inner">
|
<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">
|
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between">
|
||||||
Managing Encounter: {selectedEncounter.name}
|
<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>
|
</h3>
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
{/* Combat Controls - Left Side (Sticky on large screens) */}
|
{/* Combat Controls - always left */}
|
||||||
<div className="lg:w-64 flex-shrink-0">
|
<div className="w-32 md:w-64 flex-shrink-0 sticky top-4 self-start">
|
||||||
<InitiativeControls
|
<InitiativeControls
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
encounter={selectedEncounter}
|
encounter={selectedEncounter}
|
||||||
@@ -2538,11 +2813,53 @@ function AdminView({ userId }) {
|
|||||||
|
|
||||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||||
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
|
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 encounterStartedRef = useRef(false);
|
||||||
const encounterActiveRef = useRef(false);
|
const encounterActiveRef = useRef(false);
|
||||||
const manualSelectRef = useRef(false);
|
const manualSelectRef = useRef(false);
|
||||||
const prevDisplayCampaignRef = useRef(null);
|
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.
|
// External display change (replay/other DM) = clear manual override, allow follow.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2761,7 +3078,7 @@ function AdminView({ userId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-3 md:space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<button
|
<button
|
||||||
@@ -2913,13 +3230,14 @@ function AdminView({ userId }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedCampaign && (
|
{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">
|
<h2 className="text-2xl font-semibold text-amber-300 mb-4 font-cinzel tracking-wide">
|
||||||
Managing: {selectedCampaign.name}
|
Managing: {selectedCampaign.name}
|
||||||
</h2>
|
</h2>
|
||||||
<CharacterManager
|
<CharacterManager
|
||||||
campaignId={selectedCampaignId}
|
campaignId={selectedCampaignId}
|
||||||
campaignCharacters={selectedCampaign.characters || []}
|
campaignCharacters={selectedCampaign.characters || []}
|
||||||
|
syncCharacters={!!selectedCampaign.syncCharacters}
|
||||||
/>
|
/>
|
||||||
<hr className="my-6 border-stone-700" />
|
<hr className="my-6 border-stone-700" />
|
||||||
<EncounterManager
|
<EncounterManager
|
||||||
@@ -3639,7 +3957,7 @@ function LogsView() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto p-4 md:p-8">
|
<main className="mx-auto p-4 md:p-8 max-w-7xl">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner message="Loading logs..." />
|
<LoadingSpinner message="Loading logs..." />
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
|
|||||||
const addOne = async (name, hp, mod, init) => {
|
const addOne = async (name, hp, mod, init) => {
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
|
||||||
|
// Two tabs on different campaigns don't fight over selection/scroll.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { setupReady, addMonsterViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
describe('localStorage campaign scoping', () => {
|
||||||
|
test('selectedEncounter key is scoped (not bare)', async () => {
|
||||||
|
await setupReady('ScopeCamp1', 'ScopeEnc1');
|
||||||
|
// bare key should not exist
|
||||||
|
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
|
||||||
|
// scoped key with suffix should exist
|
||||||
|
const scoped = Object.keys(localStorage).find(
|
||||||
|
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
|
||||||
|
);
|
||||||
|
expect(scoped).toBeTruthy();
|
||||||
|
expect(localStorage.getItem(scoped)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrollY key is scoped (not bare)', async () => {
|
||||||
|
await setupReady('ScopeCamp2', 'ScopeEnc2');
|
||||||
|
await addMonsterViaUI('Goblin', 10, 5);
|
||||||
|
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
|
||||||
|
const scoped = Object.keys(localStorage).find(
|
||||||
|
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
|
||||||
|
);
|
||||||
|
// key may not exist until save fires — trigger by checking bare is null
|
||||||
|
// and at least scoped format is used if any scrollY key present
|
||||||
|
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
|
||||||
|
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global keys still exist (campaign selection, wake lock)', async () => {
|
||||||
|
await setupReady('ScopeCamp3', 'ScopeEnc3');
|
||||||
|
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
|
|||||||
async function addOne(form, name, hp, mod, init) {
|
async function addOne(form, name, hp, mod, init) {
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
|
|||||||
const form = within(getParticipantForm());
|
const form = within(getParticipantForm());
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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 }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user