Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic. Generic mode: - No death saves (deathSave/stabilize throw) - Negative HP allowed (no clamp at 0) - <=0 HP = status 'down' (no pips, no unconscious auto-condition) - Monster death = dead + inactive (same as 5e) - markDead button: DM sets dead manually (both modes) - revive: dead->conscious (generic), dead->stable (5e) 5e mode: zero behavior change. shared/turn.js: - applyHpChangeGeneric: negative HP, down status, no death-save logic - markDead: status dead, monster auto-inactive - reviveParticipant: ruleset-aware (generic=conscious, 5e=stable) - deathSave/stabilize: throw in generic - expandUndo: mark_dead case added UI (src/App.js): - CreateCampaignForm + CreateEncounterForm: ruleset radio toggle - handleCreateCampaign/handleCreateEncounter: store ruleset field - EncounterManager: fetch campaignDoc for default ruleset inheritance - DM participant: down/dead labels, Mark Dead button (generic), Revive both - death-save pips/buttons gated 5e-only - DisplayView: down label, generic status derivation Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
This commit is contained in:
+74
-12
@@ -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"
|
||||
@@ -1153,6 +1182,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 +1452,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 +1494,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 +1520,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>
|
||||
)}
|
||||
@@ -1950,6 +1999,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
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 +2042,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 +2055,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
ruleset: ruleset || '5e',
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2153,8 +2204,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>
|
||||
)}
|
||||
@@ -2283,7 +2336,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 +2348,7 @@ function AdminView({ userId }) {
|
||||
ownerId: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [],
|
||||
ruleset: ruleset || '5e',
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
@@ -2478,6 +2532,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>
|
||||
);
|
||||
@@ -2847,8 +2907,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 +2943,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