Generic (non-5e) ruleset mode + UI polish + combat switch fix #7

Merged
robert merged 25 commits from feat/generic-ruleset into main 2026-07-08 22:54:04 -04:00
5 changed files with 110 additions and 11 deletions
Showing only changes of commit ebd91ca42a - Show all commits
+2 -1
View File
@@ -11,7 +11,7 @@ also better vert tab layout - labelt friendly
x needs AC for players dude x needs AC for players dude
and quick entry hp x and quick entry hp
hp do not carry from encounter to ecnounter!!! hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter? ^feature to add back to campaign character after encounter?
@@ -20,6 +20,7 @@ maybe campaign toggle in charc section (choosing each end is DM overload will be
hp wont go over max and no temp hp support hp wont go over max and no temp hp support
refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
+105 -7
View File
@@ -1085,6 +1085,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
]; ];
const participants = encounter.participants || []; const participants = encounter.participants || [];
const [editingField, setEditingField] = useState(null);
const [editingId, setEditingId] = useState(null);
const blurTimeoutRef = useRef(null);
const handleFieldFocus = (id, label) => {
if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; }
setEditingId(id);
setEditingField(label);
};
const handleFieldBlur = () => {
blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150);
};
useEffect(() => { useEffect(() => {
if (participantType === 'character' && selectedCharacterId) { if (participantType === 'character' && selectedCharacterId) {
@@ -1283,6 +1294,53 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} }
}; };
const handleInlineCurrentHp = async (participantId, value) => {
let n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.currentHp) return;
try {
const isGeneric = (encounter.ruleset === 'generic');
let patch;
if (!isGeneric && n < 0) n = 0;
if (isGeneric) {
patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' };
} else {
patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 };
if (n > 0) { patch.status = 'conscious'; }
else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; }
else { patch.status = 'dying'; }
}
await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update HP.');
}
};
const handleInlineMaxHp = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.maxHp || n <= 0) return;
try {
await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update Max HP.');
}
};
const handleInlineAc = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.ac) return;
try {
await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update AC.');
}
};
const handleDeathSaveChange = async (participantId, outcome) => { const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return; if (!db) return;
try { try {
@@ -1602,6 +1660,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>} {participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
<ul className="space-y-2"> <ul className="space-y-2">
{sortedParticipants.map((p) => { {sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
@@ -1628,8 +1687,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined} onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)} onDragEnd={() => setDraggedItemId(null)}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`} className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
> >
{editingId === p.id && (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="px-4 py-1 bg-amber-600 rounded-full text-white text-sm font-bold shadow-lg">
Editing {editingField}
</span>
</span>
)}
<div className="flex-1 flex items-start sm:items-center"> <div className="flex-1 flex items-start sm:items-center">
{isDraggable && ( {isDraggable && (
<ChevronsUpDown <ChevronsUpDown
@@ -1645,7 +1711,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{p.ac != null && ( {p.ac != null && (
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded"> <span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span> <span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span> <input
type="number"
defaultValue={p.ac}
key={`ac-${p.ac}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'AC'); }}
onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }}
className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`AC for ${p.name}`}
/>
</span> </span>
)} )}
{isCurrentTurn && !encounter.isPaused && ( {isCurrentTurn && !encounter.isPaused && (
@@ -1665,19 +1740,42 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
type="number" type="number"
id={`init-${p.id}`} id={`init-${p.id}`}
defaultValue={p.initiative} defaultValue={p.initiative}
key={p.initiative} key={`init-${p.initiative}`}
min="0" min="0"
max="99" max="99"
disabled={encounter.isStarted && !encounter.isPaused} disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }} onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()} onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }} onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed" className="w-6 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Initiative for ${p.name}`} aria-label={`Initiative for ${p.name}`}
/> />
</span> </span>
<span>HP: {p.currentHp}/{p.maxHp}</span> <span className="inline-flex items-baseline">
<input
type="number"
id={`hp-${p.id}`}
defaultValue={p.currentHp}
key={`hp-${p.currentHp}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }}
className="w-8 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Current HP for ${p.name}`}
/>
<span className="text-stone-500 text-sm">/</span>
<input
type="number"
defaultValue={p.maxHp}
key={`maxhp-${p.maxHp}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
className="w-8 bg-transparent text-stone-400 text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Max HP for ${p.name}`}
/>
</span>
</div> </div>
{participantStatus === 'dead' && ( {participantStatus === 'dead' && (
+1 -1
View File
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
const addOne = async (name, hp, mod, init) => { const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } }); fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } }); fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } }); fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
async function addOne(form, name, hp, mod, init) { async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } }); fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } }); fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } }); fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } }); fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } }); fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(maxHp) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db'); const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => { await waitFor(() => {