Compare commits
3
Commits
7955bed4a9
...
95db6fdfdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95db6fdfdc | ||
|
|
a5b6d61e83 | ||
|
|
c2bb5cca29 |
@@ -50,19 +50,6 @@ lots of updates
|
||||
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
||||
|
||||
### BUG: select campaign during active combat = screen flicker, no action
|
||||
- In active encounter, click different campaign → flicker, no nav, no end.
|
||||
- Either block (toast: end encounter first) or auto-end current + switch.
|
||||
- Decide UX before fix.
|
||||
|
||||
|
||||
### FEAT: generic/non-5e rules mode
|
||||
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
|
||||
- Generic mode turns off death saves/state machine.
|
||||
- Generic mode allows negative HP.
|
||||
- 5e mode rejects negative damage/heal and clamps HP at 0.
|
||||
|
||||
|
||||
|
||||
### FEAT: clarify "Is NPC" in add-participant
|
||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
|
||||
} = shared;
|
||||
|
||||
function char(id, hp = 100) {
|
||||
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
|
||||
participant.id = id;
|
||||
return participant;
|
||||
}
|
||||
function mon(id, hp = 100) {
|
||||
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
|
||||
participant.id = id;
|
||||
return participant;
|
||||
}
|
||||
function enc(ps, ruleset = 'generic') {
|
||||
return {
|
||||
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
ruleset,
|
||||
};
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
|
||||
describe('generic ruleset', () => {
|
||||
test('damage past 0 = negative HP, status down (character)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(-5);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('damage exactly to 0 = status down', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('heal from negative = conscious', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(5);
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
|
||||
test('heal caps at maxHp', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(10);
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
|
||||
test('monster death = auto-inactive (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
|
||||
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||
expect(p.currentHp).toBe(-10);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('monster at exactly 0 = dead + inactive (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('deathSave throws in generic mode', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('revive in generic mode: dead→conscious (no stable)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
e = await markDead(e, 'a', ctx); // dead
|
||||
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('conscious');
|
||||
expect(p.currentHp).toBe(0); // HP unchanged from down
|
||||
});
|
||||
|
||||
test('revive generic reactivates if inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
|
||||
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
|
||||
const p = newEnc.participants[0];
|
||||
expect(p.status).toBe('conscious');
|
||||
expect(p.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('markDead sets status dead (character, stays active)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
const newEnc = await markDead(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('markDead on monster = dead + inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([mon('m', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
|
||||
const newEnc = await markDead(e, e.participants[0].id, ctx);
|
||||
const p = newEnc.participants[0];
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('markDead on conscious = dead', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await markDead(e, 'a', ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('dead');
|
||||
});
|
||||
|
||||
test('damage while down = more negative (no death saves)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.currentHp).toBe(-10);
|
||||
expect(p.status).toBe('down');
|
||||
});
|
||||
|
||||
test('no unconscious condition auto-added (generic)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.conditions).not.toContain('unconscious');
|
||||
});
|
||||
|
||||
test('down→conscious clears dead? no, dead is separate', async () => {
|
||||
// sanity: down heal to conscious, dead untouched
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([char('a', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||
const p = newEnc.participants.find(x => x.id === 'a');
|
||||
expect(p.status).toBe('conscious');
|
||||
});
|
||||
});
|
||||
+115
-2
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
|
||||
case 'death_save':
|
||||
case 'stabilize':
|
||||
case 'revive':
|
||||
case 'mark_dead':
|
||||
case 'deactivate_dead_monster': {
|
||||
// Full death-state restore: currentHp, status, death-save counters,
|
||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||
const ctx = maybeCtx || optionsOrCtx;
|
||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (isNaN(amount) || amount === 0) return encounter;
|
||||
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
|
||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
if (ruleset === 'generic') {
|
||||
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
|
||||
}
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (participant.currentHp === 0) {
|
||||
if (status === 'dead') {
|
||||
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||
const participantId = participant.id;
|
||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (oldValues.status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, isActive: false } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: 0, deathSaveFailures: 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
return encounter;
|
||||
}
|
||||
const newHp = participant.currentHp - amount;
|
||||
const isMonster = participant.type === 'monster';
|
||||
if (newHp <= 0 && isMonster) {
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
||||
} else if (newHp <= 0) {
|
||||
updates = { currentHp: newHp, status: 'down' };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
} else if (changeType === 'heal') {
|
||||
if (oldValues.status === 'dead') return encounter;
|
||||
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
|
||||
} else {
|
||||
throw new Error(`Unknown HP change type: ${changeType}`);
|
||||
}
|
||||
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...updates } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: changeType, participantId, participantName: participant.name,
|
||||
message, delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Death saves not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
||||
}
|
||||
|
||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Stabilize not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
}
|
||||
|
||||
async function reviveParticipant(encounter, participantId, ctx) {
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
if (status !== 'dead') return encounter;
|
||||
|
||||
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
|
||||
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
|
||||
const updates = ruleset === 'generic'
|
||||
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
|
||||
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
|
||||
async function markDead(encounter, participantId, ctx) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (participant.status === 'dead') return encounter;
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
status: participant.status || 'conscious',
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'mark_dead', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked dead`,
|
||||
delta: { status: 'dead' },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
const participants = [...(encounter.participants || [])];
|
||||
const dragged = participants.find(p => p.id === draggedId);
|
||||
@@ -919,7 +1032,7 @@ module.exports = {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+129
-25
@@ -5,7 +5,7 @@ import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
|
||||
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
|
||||
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
|
||||
Undo2, Redo2, Crosshair
|
||||
} from 'lucide-react';
|
||||
@@ -125,6 +125,7 @@ const {
|
||||
deathSave: combatDeathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
markDead,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = shared;
|
||||
@@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) {
|
||||
function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
const [ruleset, setRuleset] = useState('5e');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name, backgroundUrl);
|
||||
onCreate(name, backgroundUrl, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -484,6 +486,19 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
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-amber-600 focus:border-amber-600 sm:text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||||
D&D 5e (death saves)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||||
Generic (no death saves, negative HP)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -503,13 +518,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEncounterForm({ onCreate, onCancel }) {
|
||||
function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
|
||||
const [name, setName] = useState('');
|
||||
const [ruleset, setRuleset] = useState(defaultRuleset);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name);
|
||||
onCreate(name, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,6 +544,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
|
||||
D&D 5e (death saves)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-200">
|
||||
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
|
||||
Generic (no death saves, negative HP)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -653,7 +682,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
|
||||
catch { return true; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); }
|
||||
catch {}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddCharacter = async () => {
|
||||
if (!db || !characterName.trim() || !campaignId) return;
|
||||
@@ -740,15 +776,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
<>
|
||||
<div className="p-4 bg-stone-900 rounded-lg shadow">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
|
||||
<Users size={24} className="mr-2" /> Campaign Characters
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 text-stone-400 hover:text-stone-200"
|
||||
aria-label={isOpen ? "Collapse" : "Expand"}
|
||||
className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
{isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
|
||||
<Users size={24} className="mr-2" /> Campaign Characters
|
||||
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1153,6 +1188,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkDead = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await markDead(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCritDamage = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
@@ -1414,14 +1458,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
||||
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
|
||||
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
|
||||
const isGeneric = (encounter.ruleset || '5e') === 'generic';
|
||||
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
|
||||
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||
|
||||
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const participantStatus = p.status || (isGeneric
|
||||
? (p.currentHp <= 0 ? 'down' : 'conscious')
|
||||
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
|
||||
const isZeroHp = p.currentHp <= 0;
|
||||
const isDead = participantStatus === 'dead';
|
||||
const isDown = participantStatus === 'down';
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -1452,6 +1500,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
)}
|
||||
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
|
||||
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
|
||||
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</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>
|
||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||
@@ -1477,11 +1526,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</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 to 0 HP, stable and unconscious">
|
||||
<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' && (
|
||||
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable — regains 1 HP after 1d4 hours</div>
|
||||
)}
|
||||
@@ -1944,12 +1999,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
// ENCOUNTER MANAGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) {
|
||||
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
|
||||
const { showToast } = useUIFeedback();
|
||||
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
|
||||
campaignId ? getPath.encounters(campaignId) : null
|
||||
);
|
||||
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
|
||||
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||||
|
||||
const [encounters, setEncounters] = useState([]);
|
||||
const [selectedEncounterId, setSelectedEncounterId] = useState(null);
|
||||
@@ -1992,7 +2048,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
}
|
||||
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
|
||||
|
||||
const handleCreateEncounter = async (name) => {
|
||||
const handleCreateEncounter = async (name, ruleset = '5e') => {
|
||||
if (!db || !name.trim() || !campaignId) return;
|
||||
|
||||
const newEncounterId = generateId();
|
||||
@@ -2005,7 +2061,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
ruleset: ruleset || '5e',
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2079,6 +2136,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
|
||||
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId);
|
||||
|
||||
useEffect(() => {
|
||||
if (encounterStartedRef) {
|
||||
encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused);
|
||||
}
|
||||
if (encounterActiveRef) {
|
||||
encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted);
|
||||
}
|
||||
}, [selectedEncounter, encounterStartedRef, encounterActiveRef]);
|
||||
|
||||
if (isLoadingEncounters && campaignId) {
|
||||
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
|
||||
}
|
||||
@@ -2115,9 +2181,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
|
||||
<h4 className="font-medium text-white">{encounter.name}</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></h4>
|
||||
<p className="text-xs text-stone-300">
|
||||
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>
|
||||
{isLive && (
|
||||
<span className="text-xs text-green-400 font-semibold block mt-1">
|
||||
@@ -2153,8 +2219,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
{showCreateModal && (
|
||||
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
|
||||
<CreateEncounterForm
|
||||
key={campaignId}
|
||||
onCreate={handleCreateEncounter}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
defaultRuleset={campaignDoc?.ruleset || '5e'}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -2212,6 +2280,19 @@ function AdminView({ userId }) {
|
||||
|
||||
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
|
||||
const encounterStartedRef = useRef(false);
|
||||
const encounterActiveRef = useRef(false);
|
||||
const manualSelectRef = useRef(false);
|
||||
const prevDisplayCampaignRef = useRef(null);
|
||||
|
||||
// External display change (replay/other DM) = clear manual override, allow follow.
|
||||
useEffect(() => {
|
||||
const cur = initialActiveInfo?.activeCampaignId || null;
|
||||
if (cur !== prevDisplayCampaignRef.current) {
|
||||
manualSelectRef.current = false;
|
||||
prevDisplayCampaignRef.current = cur;
|
||||
}
|
||||
}, [initialActiveInfo]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
@@ -2271,10 +2352,15 @@ function AdminView({ userId }) {
|
||||
}, [campaignsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip follow only if user manually selected AND display hasn't changed.
|
||||
// (external display change clears manualSelectRef via prevDisplay effect)
|
||||
if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return;
|
||||
if (
|
||||
initialActiveInfo &&
|
||||
initialActiveInfo.activeCampaignId &&
|
||||
campaignsWithDetails.length > 0
|
||||
campaignsWithDetails.length > 0 &&
|
||||
!encounterStartedRef.current &&
|
||||
!encounterActiveRef.current
|
||||
) {
|
||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||
@@ -2283,7 +2369,7 @@ function AdminView({ userId }) {
|
||||
}
|
||||
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
|
||||
|
||||
const handleCreateCampaign = async (name, backgroundUrl) => {
|
||||
const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
|
||||
if (!db || !name.trim()) return;
|
||||
|
||||
const newCampaignId = generateId();
|
||||
@@ -2295,6 +2381,7 @@ function AdminView({ userId }) {
|
||||
ownerId: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [],
|
||||
ruleset: ruleset || '5e',
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2446,7 +2533,14 @@ function AdminView({ userId }) {
|
||||
return (
|
||||
<div
|
||||
key={campaign.id}
|
||||
onClick={() => setSelectedCampaignId(campaign.id)}
|
||||
onClick={() => {
|
||||
if (encounterStartedRef.current) {
|
||||
showToast('End or pause active encounter before switching campaigns.');
|
||||
return;
|
||||
}
|
||||
manualSelectRef.current = true;
|
||||
setSelectedCampaignId(campaign.id);
|
||||
}}
|
||||
className={cardClasses}
|
||||
style={cardStyle}
|
||||
>
|
||||
@@ -2478,6 +2572,12 @@ function AdminView({ userId }) {
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" /> Delete
|
||||
</button>
|
||||
<span
|
||||
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
|
||||
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
|
||||
>
|
||||
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2514,6 +2614,8 @@ function AdminView({ userId }) {
|
||||
: null
|
||||
}
|
||||
campaignCharacters={selectedCampaign.characters || []}
|
||||
encounterStartedRef={encounterStartedRef}
|
||||
encounterActiveRef={encounterActiveRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2847,8 +2949,9 @@ function DisplayView() {
|
||||
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{participantsToRender.map(p => {
|
||||
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
|
||||
const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious'));
|
||||
const isZeroHp = p.currentHp <= 0;
|
||||
let participantBgColor = p.type === 'monster' || p.type === 'npc'
|
||||
? 'bg-[#8e351c]'
|
||||
: 'bg-indigo-950';
|
||||
@@ -2882,6 +2985,7 @@ function DisplayView() {
|
||||
)}
|
||||
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
|
||||
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
|
||||
{status === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</span>}
|
||||
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
|
||||
</h3>
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user