refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared: startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s), updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants. ONE code path for UI. shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md): - addParticipant: splice into slot by initiative (no sort) - updateParticipant: re-slot on initiative change (no sort) - reorderParticipants: same-init drag only (cross-init blocked) - applyHpChange: death flips isActive=false (matches App), revive reactivates No renames, no API changes. Shared func signatures unchanged. Tests updated to match unified behavior: - dead-skip: death deactivates (was FEAT-1 stays active) - characterization: death deactivates + revive reactivates - reorder/invariant: cross-init drag blocked, same-init allowed Both suites green: shared 91/91, App 77/77, 0 warnings.
This commit is contained in:
@@ -213,22 +213,21 @@ describe('applyHpChange', () => {
|
|||||||
expect(patch.participants[0].currentHp).toBe(10);
|
expect(patch.participants[0].currentHp).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => {
|
test('damage to 0 deactivates + keeps turn order (unified)', () => {
|
||||||
// FEAT-1: death no longer deactivates or removes from turn order.
|
// Unified: death flips isActive=false (removed from active rotation).
|
||||||
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn.
|
// turnOrderIds unchanged (no turn-order patch on death).
|
||||||
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
||||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||||
const { patch } = applyHpChange(e, 'a', 'damage', 5);
|
const { patch } = applyHpChange(e, 'a', 'damage', 5);
|
||||||
expect(patch.participants[0].currentHp).toBe(0);
|
expect(patch.participants[0].currentHp).toBe(0);
|
||||||
expect(patch.participants[0].isActive).toBe(true);
|
expect(patch.participants[0].isActive).toBe(false);
|
||||||
expect(patch.turnOrderIds).toBeUndefined();
|
expect(patch.turnOrderIds).toBeUndefined();
|
||||||
expect(patch.currentTurnParticipantId).toBeUndefined();
|
expect(patch.currentTurnParticipantId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => {
|
test('heal above 0 reactivates + resets death saves (unified)', () => {
|
||||||
// FEAT-1: revive no longer flips isActive (was already active — death
|
// Unified: revive from 0 flips isActive=true, deathSaves reset.
|
||||||
// doesn't deactivate). deathSaves still reset.
|
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
|
||||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
|
||||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
|
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
|
||||||
expect(patch.participants[0].currentHp).toBe(5);
|
expect(patch.participants[0].currentHp).toBe(5);
|
||||||
expect(patch.participants[0].isActive).toBe(true);
|
expect(patch.participants[0].isActive).toBe(true);
|
||||||
@@ -285,17 +284,17 @@ describe('toggleCondition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('reorderParticipants', () => {
|
describe('reorderParticipants', () => {
|
||||||
test('drag before target (1-list, cross-init allowed)', () => {
|
test('drag before target (same-init tie)', () => {
|
||||||
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||||
const { patch } = reorderParticipants(enc(ps), 'a', 'c');
|
const { patch } = reorderParticipants(enc(ps), 'a', 'c');
|
||||||
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
|
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
|
||||||
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
|
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cross-init drag allowed (1-list, DM override)', () => {
|
test('cross-init drag blocked (no-op)', () => {
|
||||||
const ps = [p('a', 10), p('b', 5)];
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
|
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
|
||||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']);
|
expect(patch).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// M4 desired behavior: dead PC stays in turn order, turn still comes up,
|
// Unified behavior (App main): death flips isActive=false, dead participant
|
||||||
// deathSave fires. Current code filters isActive (set false on death) so
|
// removed from active rotation, skipped by nextTurn. deathSave is a manual
|
||||||
// dead participants are SKIPPED. Test asserts desired state = RED on current.
|
// DM action (button), not tied to rotation. turnOrderIds unchanged on death
|
||||||
|
// (only isActive flag flips). Revive (heal from 0) reactivates.
|
||||||
|
//
|
||||||
|
// Previous "M4: dead stays in rotation" concept reversed by unification.
|
||||||
|
|
||||||
const shared = require('@ttrpg/shared');
|
const shared = require('@ttrpg/shared');
|
||||||
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
||||||
@@ -24,8 +27,8 @@ function enc(ps) {
|
|||||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('M4: dead participants stay in turn order', () => {
|
describe('unified: death deactivates, dead skipped in rotation', () => {
|
||||||
test('dead PC not removed from turnOrderIds', () => {
|
test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
|
||||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||||
let e = enc(ps);
|
let e = enc(ps);
|
||||||
e = { ...e, ...startEncounter(e).patch };
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
@@ -35,39 +38,53 @@ describe('M4: dead participants stay in turn order', () => {
|
|||||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dead PC turn still comes up (nextTurn visits them)', () => {
|
test('dead PC skipped by nextTurn (isActive=false)', () => {
|
||||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||||
let e = enc(ps);
|
let e = enc(ps);
|
||||||
e = { ...e, ...startEncounter(e).patch };
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
// kill b
|
// kill b
|
||||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
// advance: a→b→c. b's turn should come up.
|
// advance: a→c (b skipped, inactive)
|
||||||
e = { ...e, ...nextTurn(e).patch };
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
expect(e.currentTurnParticipantId).toBe('b');
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dead PC on their turn can deathSave', () => {
|
test('dead PC deathSave fires on manual call (not via rotation)', () => {
|
||||||
const ps = [pc('a', 20), pc('b', 15)];
|
const ps = [pc('a', 20), pc('b', 15)];
|
||||||
let e = enc(ps);
|
let e = enc(ps);
|
||||||
e = { ...e, ...startEncounter(e).patch };
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
// kill b (current = a)
|
// kill b (current = a)
|
||||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
// advance to b's turn
|
// b is dead: DM can still fire deathSave (manual action)
|
||||||
e = { ...e, ...nextTurn(e).patch };
|
|
||||||
expect(e.currentTurnParticipantId).toBe('b');
|
|
||||||
// b is dead, on their turn: deathSave should not throw
|
|
||||||
const r = deathSave(e, 'b', 1);
|
const r = deathSave(e, 'b', 1);
|
||||||
expect(r.patch).toBeTruthy();
|
expect(r.patch).toBeTruthy();
|
||||||
const b = r.patch.participants.find(x => x.id === 'b');
|
const b = r.patch.participants.find(x => x.id === 'b');
|
||||||
expect(b.deathSaves).toBe(1);
|
expect(b.deathSaves).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dead PC not auto-set isActive=false by applyHpChange', () => {
|
// D1 unification: shared now matches App main — death flips isActive=false.
|
||||||
|
// Dead participant removed from active rotation (skipped by nextTurn).
|
||||||
|
test('dead PC auto-set isActive=false by applyHpChange', () => {
|
||||||
const ps = [pc('a', 20), pc('b', 15)];
|
const ps = [pc('a', 20), pc('b', 15)];
|
||||||
let e = enc(ps);
|
let e = enc(ps);
|
||||||
e = { ...e, ...startEncounter(e).patch };
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
const b = e.participants.find(x => x.id === 'b');
|
const b = e.participants.find(x => x.id === 'b');
|
||||||
expect(b.isActive).toBe(true);
|
expect(b.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive (heal from 0) reactivates participant', () => {
|
||||||
|
const ps = [pc('a', 20), pc('b', 15)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
|
const dead = e.participants.find(x => x.id === 'b');
|
||||||
|
expect(dead.isActive).toBe(false);
|
||||||
|
// heal b back up
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch };
|
||||||
|
const revived = e.participants.find(x => x.id === 'b');
|
||||||
|
expect(revived.isActive).toBe(true);
|
||||||
|
expect(revived.currentHp).toBe(50);
|
||||||
|
expect(revived.deathSaves).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,16 +82,16 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () =
|
|||||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reorder cross-init: allowed, list + rotation reflect new order', () => {
|
test('reorder cross-init: blocked (no-op)', () => {
|
||||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
e = apply(e, startEncounter(e)); // [a,b,c]
|
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||||
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a
|
const r = reorderParticipants(e, 'c', 'a'); // cross-init
|
||||||
expect(e.turnOrderIds).toEqual(['c','a','b']);
|
expect(r.patch).toBeNull();
|
||||||
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reorder: rotation follows new list order', () => {
|
test('reorder same-init: rotation follows new list order', () => {
|
||||||
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie
|
||||||
e = apply(e, startEncounter(e)); // [a,b,c], cur=a
|
e = apply(e, startEncounter(e)); // [a,b,c], cur=a
|
||||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
|
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
|
||||||
const rot = walkRotation(e); // start a, next c (wrap), next b, back a
|
const rot = walkRotation(e); // start a, next c (wrap), next b, back a
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ describe('reorderParticipants', () => {
|
|||||||
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
|
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cross-init drag allowed (1-list, DM override)', () => {
|
test('cross-init drag blocked (no-op)', () => {
|
||||||
const ps = [p('a', 10), p('b', 20)];
|
const ps = [p('a', 10), p('b', 20)];
|
||||||
let e = enc(ps);
|
let e = enc(ps);
|
||||||
e = { ...e, ...startEncounter(e).patch }; // [b,a]
|
e = { ...e, ...startEncounter(e).patch }; // [b,a]
|
||||||
const r = reorderParticipants(e, 'a', 'b');
|
const r = reorderParticipants(e, 'a', 'b');
|
||||||
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']);
|
expect(r.patch).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws if id not found', () => {
|
test('throws if id not found', () => {
|
||||||
|
|||||||
+24
-29
@@ -311,26 +311,14 @@ function addParticipant(encounter, participant) {
|
|||||||
if ((encounter.participants || []).some(p => p.id === participant.id)) {
|
if ((encounter.participants || []).some(p => p.id === participant.id)) {
|
||||||
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
|
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
|
||||||
}
|
}
|
||||||
// 1-list: splice participant into participants[] by initiative position,
|
// FEAT-3: reslot by initiative on add (stable sort, tie-break original index).
|
||||||
// then sync turnOrderIds = participants.map(id).
|
// Pre-combat: list reflects init order immediately. Post-combat: slots into running order.
|
||||||
let updatedParticipants;
|
const updatedParticipants = sortParticipantsByInitiative(
|
||||||
let insertAt;
|
[...(encounter.participants || []), participant],
|
||||||
if (!encounter.isStarted) {
|
encounter.participants || []
|
||||||
updatedParticipants = [...(encounter.participants || []), participant];
|
);
|
||||||
} else {
|
|
||||||
const { insertAt: at } = computeTurnOrderAfterAddition(
|
|
||||||
{ ...encounter, participants: [...(encounter.participants || []), participant] },
|
|
||||||
participant.id);
|
|
||||||
insertAt = at !== undefined ? at : (encounter.participants || []).length;
|
|
||||||
updatedParticipants = [
|
|
||||||
...(encounter.participants || []).slice(0, insertAt),
|
|
||||||
participant,
|
|
||||||
...(encounter.participants || []).slice(insertAt),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
|
|
||||||
return {
|
return {
|
||||||
patch: { participants: updatedParticipants, ...turnUpdates },
|
patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
|
||||||
log: {
|
log: {
|
||||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||||
undo: {
|
undo: {
|
||||||
@@ -354,7 +342,10 @@ function updateParticipant(encounter, participantId, updatedData) {
|
|||||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
p.id === participantId ? { ...p, ...updatedData } : p
|
p.id === participantId ? { ...p, ...updatedData } : p
|
||||||
);
|
);
|
||||||
return { patch: { participants: updatedParticipants }, log: null };
|
// FEAT-3: reslot by initiative so any init change reflects in list order
|
||||||
|
// immediately (stable sort, tie-break = original array index).
|
||||||
|
const reslotted = sortParticipantsByInitiative(updatedParticipants, encounter.participants || []);
|
||||||
|
return { patch: { participants: reslotted, ...syncTurnOrder(reslotted) }, log: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
||||||
@@ -427,24 +418,24 @@ function applyHpChange(encounter, participantId, changeType, amount) {
|
|||||||
const isDead = newHp === 0;
|
const isDead = newHp === 0;
|
||||||
const wasResurrected = wasDead && newHp > 0;
|
const wasResurrected = wasDead && newHp > 0;
|
||||||
|
|
||||||
// FEAT-1: death no longer flips isActive or touches turnOrderIds.
|
// Unified (App main): death flips isActive=false (removed from active
|
||||||
// Dead participants stay in turn order, nextTurn still visits them, PCs
|
// rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change.
|
||||||
// get their death-save turn. isActive = DM-controlled combatant toggle only.
|
|
||||||
const updatedParticipants = (encounter.participants || []).map(p => {
|
const updatedParticipants = (encounter.participants || []).map(p => {
|
||||||
if (p.id !== participantId) return p;
|
if (p.id !== participantId) return p;
|
||||||
const updates = { ...p, currentHp: newHp };
|
const updates = { ...p, currentHp: newHp };
|
||||||
if (isDead && !wasDead) {
|
if (isDead && !wasDead) {
|
||||||
|
updates.isActive = false;
|
||||||
updates.deathSaves = p.deathSaves || 0;
|
updates.deathSaves = p.deathSaves || 0;
|
||||||
updates.isDying = false;
|
updates.isDying = false;
|
||||||
}
|
}
|
||||||
if (wasResurrected) {
|
if (wasResurrected) {
|
||||||
|
updates.isActive = true;
|
||||||
updates.deathSaves = 0;
|
updates.deathSaves = 0;
|
||||||
updates.isDying = false;
|
updates.isDying = false;
|
||||||
}
|
}
|
||||||
return updates;
|
return updates;
|
||||||
});
|
});
|
||||||
|
|
||||||
// No turn-order updates on death/revive (FEAT-1).
|
|
||||||
const turnUpdates = {};
|
const turnUpdates = {};
|
||||||
|
|
||||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||||
@@ -519,15 +510,19 @@ function toggleCondition(encounter, participantId, conditionId) {
|
|||||||
|
|
||||||
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
|
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
|
||||||
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
|
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
|
||||||
|
// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override).
|
||||||
|
// Cross-init drag = no-op (use edit-initiative field instead). Splice move.
|
||||||
function reorderParticipants(encounter, draggedId, targetId) {
|
function reorderParticipants(encounter, draggedId, targetId) {
|
||||||
const participants = [...(encounter.participants || [])];
|
const participants = [...(encounter.participants || [])];
|
||||||
const draggedIndex = participants.findIndex(p => p.id === draggedId);
|
const dragged = participants.find(p => p.id === draggedId);
|
||||||
const targetIndex = participants.findIndex(p => p.id === targetId);
|
const target = participants.find(p => p.id === targetId);
|
||||||
if (draggedIndex === -1 || targetIndex === -1) {
|
if (!dragged || !target) throw new Error('Dragged or target item not found.');
|
||||||
throw new Error('Dragged or target item not found.');
|
if (draggedId === targetId) return { patch: null, log: null };
|
||||||
|
if (dragged.initiative !== target.initiative) {
|
||||||
|
return { patch: null, log: null }; // cross-init blocked
|
||||||
}
|
}
|
||||||
|
const draggedIndex = participants.findIndex(p => p.id === draggedId);
|
||||||
const [removedItem] = participants.splice(draggedIndex, 1);
|
const [removedItem] = participants.splice(draggedIndex, 1);
|
||||||
// recompute targetIndex after removal (shift if dragged was before target)
|
|
||||||
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
||||||
participants.splice(newTargetIndex, 0, removedItem);
|
participants.splice(newTargetIndex, 0, removedItem);
|
||||||
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
|
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
|
||||||
|
|||||||
+88
-353
@@ -47,7 +47,19 @@ if (typeof document !== 'undefined') {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const APP_VERSION = 'v0.3';
|
const APP_VERSION = 'v0.3';
|
||||||
const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared;
|
const {
|
||||||
|
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||||
|
generateId, rollD20, formatInitMod,
|
||||||
|
sortParticipantsByInitiative, syncTurnOrder,
|
||||||
|
computeTurnOrderAfterRemoval,
|
||||||
|
startEncounter, nextTurn, togglePause, endEncounter,
|
||||||
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive: combatToggleActive,
|
||||||
|
applyHpChange: combatApplyHpChange,
|
||||||
|
deathSave: combatDeathSave,
|
||||||
|
toggleCondition: combatToggleCondition,
|
||||||
|
reorderParticipants,
|
||||||
|
} = shared;
|
||||||
const ROLL_DISPLAY_DURATION = 5000;
|
const ROLL_DISPLAY_DURATION = 5000;
|
||||||
|
|
||||||
const CONDITIONS = [
|
const CONDITIONS = [
|
||||||
@@ -825,7 +837,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
if (participantType === 'character') {
|
if (participantType === 'character') {
|
||||||
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
|
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
|
||||||
if (!character) {
|
if (!character) {
|
||||||
console.error("Selected character not found");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
|
if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) {
|
||||||
@@ -852,18 +863,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
isNpc: participantType === 'monster' ? participantIsNpc : false,
|
isNpc: participantType === 'monster' ? participantIsNpc : false,
|
||||||
conditions: [],
|
conditions: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
deathSaves: 0, // Track failed death saves (0-3)
|
deathSaves: 0,
|
||||||
isDying: false, // For death animation on player display
|
isDying: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch, log } = addParticipant(encounter, newParticipant);
|
||||||
participants: sortParticipantsByInitiative([...participants, newParticipant], participants),
|
await storage.updateDoc(encounterPath, patch);
|
||||||
...syncTurnOrder([...participants, newParticipant]),
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
});
|
|
||||||
logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
|
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: { participants: [...participants] },
|
updates: log.undo,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLastRollDetails({
|
setLastRollDetails({
|
||||||
@@ -876,7 +885,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
});
|
});
|
||||||
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setParticipantName('');
|
setParticipantName('');
|
||||||
setMaxHp(DEFAULT_MAX_HP);
|
setMaxHp(DEFAULT_MAX_HP);
|
||||||
setSelectedCharacterId('');
|
setSelectedCharacterId('');
|
||||||
@@ -884,7 +892,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
setIsNpc(false);
|
setIsNpc(false);
|
||||||
setManualInitiative('');
|
setManualInitiative('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error adding participant:", err);
|
|
||||||
alert("Failed to add participant. Please try again.");
|
alert("Failed to add participant. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -902,7 +909,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
const initiativeRoll = rollD20();
|
const initiativeRoll = rollD20();
|
||||||
const modifier = char.defaultInitMod || 0;
|
const modifier = char.defaultInitMod || 0;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: char.name,
|
name: char.name,
|
||||||
@@ -925,31 +931,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch } = addParticipants(encounter, newParticipants);
|
||||||
participants: [...participants, ...newParticipants]
|
await storage.updateDoc(encounterPath, patch);
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error adding all campaign characters:", err);
|
|
||||||
alert("Failed to add all characters. Please try again.");
|
alert("Failed to add all characters. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateParticipant = async (updatedData) => {
|
const handleUpdateParticipant = async (updatedData) => {
|
||||||
if (!db || !editingParticipant) return;
|
if (!db || !editingParticipant) return;
|
||||||
|
|
||||||
const updatedParticipants = participants.map(p =>
|
|
||||||
p.id === editingParticipant.id ? { ...p, ...updatedData } : p
|
|
||||||
);
|
|
||||||
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData);
|
||||||
participants: reslotted,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
...syncTurnOrder(reslotted),
|
|
||||||
});
|
|
||||||
setEditingParticipant(null);
|
setEditingParticipant(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating participant:", err);
|
|
||||||
alert("Failed to update participant. Please try again.");
|
alert("Failed to update participant. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -962,17 +957,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
const n = parseInt(value, 10);
|
const n = parseInt(value, 10);
|
||||||
if (isNaN(n)) return;
|
if (isNaN(n)) return;
|
||||||
const updatedParticipants = participants.map(p =>
|
|
||||||
p.id === participantId ? { ...p, initiative: n } : p
|
|
||||||
);
|
|
||||||
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch } = updateParticipant(encounter, participantId, { initiative: n });
|
||||||
participants: reslotted,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
...syncTurnOrder(reslotted),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating initiative:", err);
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -983,221 +972,89 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
|
|
||||||
const confirmDeleteParticipant = async () => {
|
const confirmDeleteParticipant = async () => {
|
||||||
if (!db || !itemToDelete) return;
|
if (!db || !itemToDelete) return;
|
||||||
|
|
||||||
const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id);
|
|
||||||
const deleteUndoData = {
|
|
||||||
encounterPath,
|
|
||||||
updates: {
|
|
||||||
participants: [...participants],
|
|
||||||
...(encounter.isStarted ? {
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch, log } = removeParticipant(encounter, itemToDelete.id);
|
||||||
participants: updatedParticipants,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
...(encounter.isStarted ? {
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
...syncTurnOrder(updatedParticipants),
|
encounterPath,
|
||||||
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants),
|
updates: log.undo,
|
||||||
} : {}),
|
|
||||||
});
|
});
|
||||||
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting participant:", err);
|
|
||||||
alert("Failed to delete participant. Please try again.");
|
alert("Failed to delete participant. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
setItemToDelete(null);
|
setItemToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleParticipantActive = async (participantId) => {
|
const toggleParticipantActive = async (participantId) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
const participant = participants.find(p => p.id === participantId);
|
|
||||||
if (!participant) return;
|
|
||||||
const newIsActive = !participant.isActive;
|
|
||||||
|
|
||||||
const updatedParticipants = participants.map(p =>
|
|
||||||
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1-list: stay in slot, flip isActive only. Sync turnOrderIds. Advance
|
|
||||||
// current only if deact hits current.
|
|
||||||
let turnUpdates = {};
|
|
||||||
if (encounter.isStarted) {
|
|
||||||
turnUpdates = syncTurnOrder(updatedParticipants);
|
|
||||||
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
|
|
||||||
turnUpdates = { ...turnUpdates, ...computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
|
const { patch, log } = combatToggleActive(encounter, participantId);
|
||||||
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, {
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: {
|
updates: log.undo,
|
||||||
participants: [...participants],
|
|
||||||
...(encounter.isStarted ? {
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling active state:", err);
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyHpChange = async (participantId, changeType) => {
|
const applyHpChange = async (participantId, changeType) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
const amountStr = hpChangeValues[participantId];
|
const amountStr = hpChangeValues[participantId];
|
||||||
if (!amountStr || amountStr.trim() === '') return;
|
if (!amountStr || amountStr.trim() === '') return;
|
||||||
|
|
||||||
const amount = parseInt(amountStr, 10);
|
const amount = parseInt(amountStr, 10);
|
||||||
if (isNaN(amount) || amount === 0) {
|
if (isNaN(amount) || amount === 0) {
|
||||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const participant = participants.find(p => p.id === participantId);
|
|
||||||
if (!participant) return;
|
|
||||||
|
|
||||||
let newHp = participant.currentHp;
|
|
||||||
if (changeType === 'damage') {
|
|
||||||
newHp = Math.max(0, participant.currentHp - amount);
|
|
||||||
} else if (changeType === 'heal') {
|
|
||||||
newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if participant died or was resurrected
|
|
||||||
const wasDead = participant.currentHp === 0;
|
|
||||||
const isDead = newHp === 0;
|
|
||||||
const wasResurrected = wasDead && newHp > 0;
|
|
||||||
|
|
||||||
const updatedParticipants = participants.map(p => {
|
|
||||||
if (p.id === participantId) {
|
|
||||||
const updates = { ...p, currentHp: newHp };
|
|
||||||
|
|
||||||
// Handle death - deactivate and start death saves
|
|
||||||
if (isDead && !wasDead) {
|
|
||||||
updates.isActive = false;
|
|
||||||
updates.deathSaves = p.deathSaves || 0;
|
|
||||||
updates.isDying = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle resurrection - reactivate and reset death saves
|
|
||||||
if (wasResurrected) {
|
|
||||||
updates.isActive = true;
|
|
||||||
updates.deathSaves = 0;
|
|
||||||
updates.isDying = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return updates;
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
|
|
||||||
const turnUpdates = {};
|
|
||||||
|
|
||||||
const hpUndoData = {
|
|
||||||
encounterPath,
|
|
||||||
updates: {
|
|
||||||
participants: [...participants],
|
|
||||||
...((isDead && !wasDead) || wasResurrected ? {
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
|
const { patch, log } = combatApplyHpChange(encounter, participantId, changeType, amount);
|
||||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
if (patch) {
|
||||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
await storage.updateDoc(encounterPath, patch);
|
||||||
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
|
if (log) {
|
||||||
const resurSuffix = wasResurrected ? ' — Revived' : '';
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
if (changeType === 'damage') {
|
encounterPath,
|
||||||
logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData);
|
updates: log.undo,
|
||||||
} else {
|
});
|
||||||
logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData);
|
}
|
||||||
}
|
}
|
||||||
|
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error applying HP change:", err);
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeathSaveChange = async (participantId, saveNumber) => {
|
const handleDeathSaveChange = async (participantId, saveNumber) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
try {
|
||||||
const participant = participants.find(p => p.id === participantId);
|
const { patch, isDying } = combatDeathSave(encounter, participantId, saveNumber);
|
||||||
if (!participant) return;
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
if (isDying) {
|
||||||
const currentSaves = participant.deathSaves || 0;
|
|
||||||
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
|
|
||||||
|
|
||||||
// If clicking the third death save, mark as dying (for player view animation)
|
|
||||||
if (newSaves === 3) {
|
|
||||||
const updatedParticipants = participants.map(p =>
|
|
||||||
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
|
||||||
|
|
||||||
// Wait for animation to complete on player display (2 seconds) then remove participant
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const finalParticipants = participants.filter(p => p.id !== participantId);
|
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
|
||||||
try {
|
await storage.updateDoc(encounterPath, { participants: finalParticipants });
|
||||||
await storage.updateDoc(encounterPath, { participants: finalParticipants });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error removing dead participant:", err);
|
|
||||||
}
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
|
||||||
console.error("Error marking participant as dying:", err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal death save update
|
|
||||||
const updatedParticipants = participants.map(p =>
|
|
||||||
p.id === participantId ? { ...p, deathSaves: newSaves } : p
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error updating death saves:", err);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCondition = async (participantId, conditionId) => {
|
const toggleCondition = async (participantId, conditionId) => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
const participant = participants.find(p => p.id === participantId);
|
|
||||||
if (!participant) return;
|
|
||||||
const wasActive = (participant.conditions || []).includes(conditionId);
|
|
||||||
const updatedParticipants = participants.map(p => {
|
|
||||||
if (p.id !== participantId) return p;
|
|
||||||
const current = p.conditions || [];
|
|
||||||
const next = wasActive
|
|
||||||
? current.filter(c => c !== conditionId)
|
|
||||||
: [...current, conditionId];
|
|
||||||
return { ...p, conditions: next };
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
|
||||||
|
await storage.updateDoc(encounterPath, patch);
|
||||||
const cond = CONDITIONS.find(c => c.id === conditionId);
|
const cond = CONDITIONS.find(c => c.id === conditionId);
|
||||||
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
||||||
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, {
|
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: { participants: [...participants] },
|
updates: log.undo,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating conditions:", err);
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1213,39 +1070,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
|
|
||||||
const handleDrop = async (e, targetId) => {
|
const handleDrop = async (e, targetId) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!db || draggedItemId === null || draggedItemId === targetId) {
|
if (!db || draggedItemId === null || draggedItemId === targetId) {
|
||||||
setDraggedItemId(null);
|
setDraggedItemId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentParticipants = [...participants];
|
|
||||||
const draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
|
|
||||||
const targetIndex = currentParticipants.findIndex(p => p.id === targetId);
|
|
||||||
|
|
||||||
if (draggedIndex === -1 || targetIndex === -1) {
|
|
||||||
console.error("Dragged or target item not found.");
|
|
||||||
setDraggedItemId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draggedItem = currentParticipants[draggedIndex];
|
|
||||||
const targetItem = currentParticipants[targetIndex];
|
|
||||||
|
|
||||||
if (draggedItem.initiative !== targetItem.initiative) {
|
|
||||||
setDraggedItemId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [removedItem] = currentParticipants.splice(draggedIndex, 1);
|
|
||||||
currentParticipants.splice(targetIndex, 0, removedItem);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, { participants: currentParticipants });
|
const { patch } = reorderParticipants(encounter, draggedItemId, targetId);
|
||||||
|
await storage.updateDoc(encounterPath, patch);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error reordering participants:", err);
|
// drag invalid (id not found) — ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraggedItemId(null);
|
setDraggedItemId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1682,74 +1516,33 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
|
||||||
if (activeParticipants.length === 0) {
|
|
||||||
alert("No active participants.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1-list model: sort ALL participants by init (active+inactive),
|
|
||||||
// first active = current. Matches shared.startEncounter.
|
|
||||||
const sortedParticipants = sortParticipantsByInitiative(encounter.participants, encounter.participants);
|
|
||||||
const firstActive = sortedParticipants.find(p => p.isActive);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch, log } = startEncounter(encounter);
|
||||||
isStarted: true,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
isPaused: false,
|
|
||||||
round: 1,
|
|
||||||
participants: sortedParticipants,
|
|
||||||
currentTurnParticipantId: firstActive.id,
|
|
||||||
turnOrderIds: sortedParticipants.map(p => p.id)
|
|
||||||
});
|
|
||||||
|
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
activeEncounterId: encounter.id
|
activeEncounterId: encounter.id
|
||||||
});
|
});
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
|
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: {
|
updates: log.undo,
|
||||||
isStarted: encounter.isStarted ?? false,
|
|
||||||
isPaused: encounter.isPaused ?? false,
|
|
||||||
round: encounter.round ?? 0,
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error starting encounter:", err);
|
alert(err.message || "Failed to start encounter. Please try again.");
|
||||||
alert("Failed to start encounter. Please try again.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePause = async () => {
|
const handleTogglePause = async () => {
|
||||||
if (!db || !encounter || !encounter.isStarted) return;
|
if (!db || !encounter || !encounter.isStarted) return;
|
||||||
|
|
||||||
const newPausedState = !encounter.isPaused;
|
|
||||||
let newTurnOrderIds = encounter.turnOrderIds;
|
|
||||||
|
|
||||||
if (!newPausedState && encounter.isPaused) {
|
|
||||||
// 1-list model: no re-sort on resume. turnOrderIds already mirrors
|
|
||||||
// participants[] (set at start/add/reorder). Resume = unpause only.
|
|
||||||
newTurnOrderIds = encounter.turnOrderIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch, log } = togglePause(encounter);
|
||||||
isPaused: newPausedState,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
turnOrderIds: newTurnOrderIds
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
});
|
|
||||||
logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, {
|
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: {
|
updates: log.undo,
|
||||||
isPaused: encounter.isPaused ?? false,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling pause state:", err);
|
// fall through silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1757,11 +1550,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
if (!db || !encounter.isStarted || encounter.isPaused) return;
|
if (!db || !encounter.isStarted || encounter.isPaused) return;
|
||||||
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
|
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
|
||||||
|
|
||||||
const activePsInOrder = encounter.turnOrderIds
|
try {
|
||||||
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
|
const { patch, log } = nextTurn(encounter);
|
||||||
.filter(Boolean);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
if (activePsInOrder.length === 0) {
|
encounterPath,
|
||||||
|
updates: log.undo,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// nextTurn throws if no active participants — auto-end combat
|
||||||
|
if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return;
|
||||||
alert("No active participants left.");
|
alert("No active participants left.");
|
||||||
await storage.updateDoc(encounterPath, {
|
await storage.updateDoc(encounterPath, {
|
||||||
isStarted: false,
|
isStarted: false,
|
||||||
@@ -1769,87 +1567,24 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
currentTurnParticipantId: null,
|
currentTurnParticipantId: null,
|
||||||
round: encounter.round
|
round: encounter.round
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
|
|
||||||
let nextRound = encounter.round;
|
|
||||||
|
|
||||||
// Current participant was removed; find the next one after their old position in turnOrderIds
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
|
|
||||||
const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)];
|
|
||||||
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
|
|
||||||
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
|
|
||||||
let newTurnOrderIds = encounter.turnOrderIds;
|
|
||||||
|
|
||||||
if (nextIndex === 0 && currentIndex !== -1) {
|
|
||||||
nextRound += 1;
|
|
||||||
// Rebuild turn order by initiative at the start of each new round so that participants
|
|
||||||
// activated mid-round (appended to the end) slot into proper initiative position next round.
|
|
||||||
const activePs = encounter.participants.filter(p => p.isActive);
|
|
||||||
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
|
|
||||||
newTurnOrderIds = sorted.map(p => p.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When wrapping to a new round the next participant is first in the rebuilt order
|
|
||||||
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
|
|
||||||
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
|
|
||||||
: activePsInOrder[nextIndex];
|
|
||||||
|
|
||||||
if (!nextParticipant) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storage.updateDoc(encounterPath, {
|
|
||||||
currentTurnParticipantId: nextParticipant.id,
|
|
||||||
round: nextRound,
|
|
||||||
turnOrderIds: newTurnOrderIds,
|
|
||||||
});
|
|
||||||
logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, {
|
|
||||||
encounterPath,
|
|
||||||
updates: {
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
||||||
round: encounter.round,
|
|
||||||
turnOrderIds: [...encounter.turnOrderIds],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error advancing turn:", err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmEndEncounter = async () => {
|
const confirmEndEncounter = async () => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(encounterPath, {
|
const { patch, log } = endEncounter(encounter);
|
||||||
isStarted: false,
|
await storage.updateDoc(encounterPath, patch);
|
||||||
isPaused: false,
|
|
||||||
currentTurnParticipantId: null,
|
|
||||||
round: 0,
|
|
||||||
turnOrderIds: []
|
|
||||||
});
|
|
||||||
|
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: null,
|
activeCampaignId: null,
|
||||||
activeEncounterId: null
|
activeEncounterId: null
|
||||||
});
|
});
|
||||||
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
|
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: {
|
updates: log.undo,
|
||||||
isStarted: encounter.isStarted ?? false,
|
|
||||||
isPaused: encounter.isPaused ?? false,
|
|
||||||
round: encounter.round ?? 0,
|
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error ending encounter:", err);
|
alert("Failed to end encounter. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowEndConfirm(false);
|
setShowEndConfirm(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user