Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 |
@@ -0,0 +1,102 @@
|
||||
// Temp HP: damage hits temp first, then regular.
|
||||
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function setupEnc() {
|
||||
const { ctx } = mockCtx();
|
||||
return {
|
||||
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
|
||||
ctx,
|
||||
};
|
||||
}
|
||||
|
||||
describe('temp HP', () => {
|
||||
test('setTempHp sets tempHp on participant', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
test('setTempHp replaces existing temp (no stacking)', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 3, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
test('setTempHp to 0 clears temp', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 0, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
});
|
||||
|
||||
test('damage: temp absorbs before regular HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(17);
|
||||
});
|
||||
|
||||
test('damage less than temp: only temp reduced', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 10, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(6);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('heal: does not affect temp HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
expect(e.participants[0].currentHp).toBe(18);
|
||||
});
|
||||
|
||||
test('damage exactly equals temp: temp gone, regular intact', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('makeParticipant: tempHp defaults to 0', async () => {
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
expect(p.tempHp).toBe(0);
|
||||
});
|
||||
});
|
||||
+82
-10
@@ -362,6 +362,7 @@ function makeParticipant(opts) {
|
||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||
hpFormula: opts.hpFormula || null,
|
||||
ac: opts.ac !== undefined ? opts.ac : null,
|
||||
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,7 +375,7 @@ function buildCharacterParticipant(character) {
|
||||
return {
|
||||
participant: makeParticipant({
|
||||
name: character.name,
|
||||
type: 'character',
|
||||
type: character.isNpc ? 'npc' : 'character',
|
||||
originalCharacterId: character.id,
|
||||
initiative: finalInitiative,
|
||||
maxHp,
|
||||
@@ -623,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
async function setTempHp(encounter, participantId, tempHp, ctx) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const value = Math.max(0, tempHp || 0);
|
||||
const oldValues = { tempHp: participant.tempHp || 0 };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: value } : p
|
||||
);
|
||||
const log = {
|
||||
type: 'set_temp_hp',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} temp HP set to ${value}`,
|
||||
delta: { tempHp: value, oldTempHp: oldValues.tempHp },
|
||||
undo: { oldValues, newValues: { tempHp: value } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, log, ctx);
|
||||
}
|
||||
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||
const ctx = maybeCtx || optionsOrCtx;
|
||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||
@@ -637,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
tempHp: participant.tempHp || 0,
|
||||
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
@@ -648,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
let message = '';
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
const currentTemp = participant.tempHp || 0;
|
||||
|
||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
@@ -656,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
}
|
||||
|
||||
if (changeType === 'damage') {
|
||||
// Temp HP absorbs damage first.
|
||||
let remainingDmg = amount;
|
||||
let tempAfter = currentTemp;
|
||||
if (tempAfter > 0) {
|
||||
const absorbed = Math.min(tempAfter, remainingDmg);
|
||||
tempAfter -= absorbed;
|
||||
remainingDmg -= absorbed;
|
||||
updates.tempHp = tempAfter;
|
||||
}
|
||||
// If fully absorbed by temp, no regular HP change.
|
||||
if (remainingDmg === 0 && participant.currentHp > 0) {
|
||||
const updatedParticipants0 = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: tempAfter } : p
|
||||
);
|
||||
const log0 = {
|
||||
type: 'hp_change',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
|
||||
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
|
||||
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
|
||||
}
|
||||
amount = remainingDmg;
|
||||
const tempBefore = currentTemp;
|
||||
const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
|
||||
if (participant.currentHp === 0) {
|
||||
if (status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
@@ -687,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
const add = options.isCriticalHit ? 2 : 1;
|
||||
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
||||
if (failures >= 3) {
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
} else {
|
||||
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
|
||||
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
|
||||
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
||||
} else if (amount >= participant.currentHp + participant.maxHp) {
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
message = `Massive damage! ${participant.name} instantly killed`;
|
||||
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
||||
} else {
|
||||
const newHp = Math.max(0, participant.currentHp - amount);
|
||||
if (newHp === 0) {
|
||||
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
|
||||
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
|
||||
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
@@ -747,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||
const participantId = participant.id;
|
||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||
const currentTemp = participant.tempHp || 0;
|
||||
|
||||
if (changeType === 'damage') {
|
||||
// Temp HP absorbs damage first.
|
||||
let remainingDmg = amount;
|
||||
let tempAfter = currentTemp;
|
||||
if (tempAfter > 0) {
|
||||
const absorbed = Math.min(tempAfter, remainingDmg);
|
||||
tempAfter -= absorbed;
|
||||
remainingDmg -= absorbed;
|
||||
}
|
||||
if (remainingDmg === 0) {
|
||||
const updatedParticipants0 = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, tempHp: tempAfter } : p
|
||||
);
|
||||
const log0 = {
|
||||
type: 'hp_change', participantId, participantName: participant.name,
|
||||
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
|
||||
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
|
||||
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
|
||||
}
|
||||
amount = remainingDmg;
|
||||
const tempFinal = tempAfter;
|
||||
if (oldValues.status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
@@ -772,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount,
|
||||
const newHp = participant.currentHp - amount;
|
||||
const isMonster = participant.type === 'monster';
|
||||
if (newHp <= 0 && isMonster) {
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
|
||||
} else if (newHp <= 0) {
|
||||
updates = { currentHp: newHp, status: 'down' };
|
||||
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
@@ -1115,7 +1187,7 @@ module.exports = {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+103
-17
@@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
|
||||
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
|
||||
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
|
||||
const [tempHp, setTempHp] = useState(participant.tempHp || 0);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
maxHp: finalMaxHp,
|
||||
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
|
||||
ac: ac !== '' ? parseInt(ac, 10) : null,
|
||||
tempHp: Math.max(0, parseInt(tempHp, 10) || 0),
|
||||
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
|
||||
});
|
||||
};
|
||||
@@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-stone-300">Temp HP</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tempHp}
|
||||
onChange={(e) => setTempHp(e.target.value)}
|
||||
min="0"
|
||||
placeholder="0"
|
||||
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-cyan-600 focus:border-cyan-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
|
||||
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
|
||||
const [defaultAc, setDefaultAc] = useState('');
|
||||
const [isCharacterNpc, setIsCharacterNpc] = useState(false);
|
||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
@@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
name: characterName.trim(),
|
||||
defaultMaxHp: hp,
|
||||
defaultInitMod: initMod,
|
||||
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
|
||||
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null,
|
||||
isNpc: isCharacterNpc || false,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
setDefaultMaxHp(DEFAULT_MAX_HP);
|
||||
setDefaultInitMod(DEFAULT_INIT_MOD);
|
||||
setDefaultAc('');
|
||||
setIsCharacterNpc(false);
|
||||
} catch (err) {
|
||||
console.error("Error adding character:", err);
|
||||
showToast("Failed to add character. Please try again."); }
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => {
|
||||
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => {
|
||||
if (!db || !newName.trim() || !campaignId) return;
|
||||
|
||||
const hp = parseInt(newDefaultMaxHp, 10);
|
||||
@@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
|
||||
const updatedCharacters = campaignCharacters.map(c =>
|
||||
c.id === characterId
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null }
|
||||
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) }
|
||||
: c
|
||||
);
|
||||
|
||||
@@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<label className="col-span-2 sm:col-span-12 flex items-center gap-2 text-xs font-medium text-stone-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isCharacterNpc}
|
||||
onChange={(e) => setIsCharacterNpc(e.target.checked)}
|
||||
className="w-4 h-4 accent-amber-600"
|
||||
/>
|
||||
Long-lived NPC (death saves like character, red name in combat)
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
|
||||
@@ -959,7 +987,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
editingCharacter.defaultMaxHp,
|
||||
editingCharacter.defaultInitMod,
|
||||
editingCharacter.defaultAc,
|
||||
editingCharacter.defaultCurrentHp
|
||||
editingCharacter.defaultCurrentHp,
|
||||
editingCharacter.isNpc
|
||||
);
|
||||
}}
|
||||
className="flex-grow flex flex-wrap gap-2 items-center"
|
||||
@@ -1013,6 +1042,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
title="AC"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1 text-[10px] text-stone-400" title="Long-lived NPC">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingCharacter.isNpc || false}
|
||||
onChange={(e) => setEditingCharacter({ ...editingCharacter, isNpc: e.target.checked })}
|
||||
className="w-3 h-3 accent-red-600"
|
||||
/>
|
||||
NPC
|
||||
</label>
|
||||
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
|
||||
<Save size={18} />
|
||||
</button>
|
||||
@@ -1028,6 +1066,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
|
||||
<>
|
||||
<div className="flex items-center mr-auto">
|
||||
<span className="text-stone-100 mr-2">{character.name}</span>
|
||||
{character.isNpc && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-900 text-red-200 text-xs font-medium border border-red-600" title="Long-lived NPC">NPC</span>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
|
||||
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
|
||||
@@ -1376,6 +1417,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleInlineTempHp = async (participantId, value) => {
|
||||
const n = Math.max(0, parseInt(value, 10) || 0);
|
||||
try {
|
||||
await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast('Failed to update Temp HP.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
@@ -1778,7 +1828,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
||||
</p>
|
||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-flex items-center px-0.5 border border-amber-500 bg-stone-950 text-amber-300 rounded">
|
||||
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1792,7 +1842,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
|
||||
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
|
||||
className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
aria-label={`Initiative for ${p.name}`}
|
||||
/>
|
||||
</span>
|
||||
@@ -1816,10 +1866,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
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-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
aria-label={`Max HP for ${p.name}`}
|
||||
/>
|
||||
</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)">
|
||||
@@ -2165,7 +2228,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
|
||||
try {
|
||||
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) {
|
||||
showToast(err.message || "Failed to start encounter. Please try again."); }
|
||||
};
|
||||
@@ -2207,7 +2276,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
} catch {}
|
||||
}
|
||||
await endEncounter(encounter, ctx);
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
// Only clear display if THIS encounter is the one showing.
|
||||
const displayIsThis = activeDisplayData &&
|
||||
activeDisplayData.activeCampaignId === campaignId &&
|
||||
activeDisplayData.activeEncounterId === encounter.id;
|
||||
if (displayIsThis) {
|
||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to end encounter. Please try again."); }
|
||||
|
||||
@@ -2337,7 +2412,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const [encounters, setEncounters] = useState([]);
|
||||
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.selectedEncounter') || null; }
|
||||
try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; }
|
||||
catch { return null; }
|
||||
});
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
@@ -2348,6 +2423,17 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||
|
||||
// Restore scoped encounter selection when campaign changes.
|
||||
useEffect(() => {
|
||||
if (!campaignId) { setSelectedEncounterId(null); return; }
|
||||
try {
|
||||
const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null;
|
||||
setSelectedEncounterId(saved);
|
||||
} catch {
|
||||
setSelectedEncounterId(null);
|
||||
}
|
||||
}, [campaignId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (encountersData) setEncounters(encountersData);
|
||||
}, [encountersData]);
|
||||
@@ -2394,10 +2480,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
useEffect(() => {
|
||||
selectedEncounterIdRef.current = selectedEncounterId;
|
||||
try {
|
||||
if (selectedEncounterId) localStorage.setItem('ttrpg.selectedEncounter', selectedEncounterId);
|
||||
else localStorage.removeItem('ttrpg.selectedEncounter');
|
||||
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
|
||||
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
|
||||
} catch {}
|
||||
}, [selectedEncounterId]);
|
||||
}, [selectedEncounterId, campaignId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId) {
|
||||
@@ -2740,7 +2826,7 @@ function AdminView({ userId }) {
|
||||
// Save scroll on unload + visibility change + interval.
|
||||
useEffect(() => {
|
||||
const onSave = () => {
|
||||
try { localStorage.setItem('ttrpg.scrollY', String(window.scrollY)); } catch {}
|
||||
try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {}
|
||||
};
|
||||
window.addEventListener('beforeunload', onSave);
|
||||
window.addEventListener('pagehide', onSave);
|
||||
@@ -2752,20 +2838,20 @@ function AdminView({ userId }) {
|
||||
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') || '0', 10);
|
||||
const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10);
|
||||
if (y > 0) {
|
||||
scrollRestoredRef.current = true;
|
||||
setTimeout(() => window.scrollTo(0, y), 300);
|
||||
}
|
||||
} catch {}
|
||||
}, [campaignsWithDetails]);
|
||||
}, [campaignsWithDetails, selectedCampaignId]);
|
||||
|
||||
// Persist selections across reload.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user