feat(FEAT-3): initiative first-class field (add + inline edit)

Add form: optional initiative field (monster + character). Empty = roll
d20+mod (current behavior). Filled = use value, skip roll. 'blank=roll'
hint + 'auto' placeholder for clarity.

Inline edit: ALL participants. Number input in participant row. Blur or
Enter commits. Capped 2 digits (max 99). Auto-select on focus for quick
overwrite. Styled to match other fields (border-stone-700, rounded-md,
shadow-sm, w-10).

handleAddParticipant: manualInit detects set value. lastRollDetails
adapts display (manual flag shows 'Set initiative' vs 'Rolled d20').

Campaign card date: Clock icon + 'Created:' prefix, own row, muted
stone-300 opacity-70. Was crammed inline with character/encounter counts.

Tests: InitiativeField.test.js (2 tests - set value + empty=roll).
RED first (field missing), then green after impl. App + Participant
characterization still green (18 total).

Note: inline init edit does NOT re-sort. Known followup — displays + list
order must reflect changed initiative. Tracked separately.
This commit is contained in:
david raistrick
2026-07-01 22:25:52 -04:00
parent a2c63cc77f
commit 0514939c51
3 changed files with 156 additions and 25 deletions
+13 -7
View File
@@ -5,6 +5,12 @@ REWORK_PLAN.md.
## Feature backlog ## Feature backlog
### CRITICAL BUG - storage
- docker for sql is not using persistant storage...
### feat - campaign section rollup
### FEAT-M6: Transactional undo (moved from REWORK_PLAN) ### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`. - Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional, - Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
@@ -17,7 +23,7 @@ REWORK_PLAN.md.
## Architecture: 1-list turn order model (DONE) ## Architecture: 1-list turn order model (DONE)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative cross-init allowed, DM choice. - Drag (reorder) overrides initiative --- cross-init allowed, DM choice.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list. - addParticipant splices by init pos. remove/toggle/reorder sync list.
- Display renders participants[] directly (no sortParticipantsByInitiative). - Display renders participants[] directly (no sortParticipantsByInitiative).
@@ -31,7 +37,7 @@ REWORK_PLAN.md.
- Separate design + RED. Own work item. - Separate design + RED. Own work item.
- Related: tie-break = drag order (current, works). Expose clearly. - Related: tie-break = drag order (current, works). Expose clearly.
### FEAT-1: Dead participants stay in turn order DONE ### FEAT-1: Dead participants stay in turn order --- DONE
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` - Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
death-save turn. `isActive` = DM toggle only. death-save turn. `isActive` = DM toggle only.
@@ -40,7 +46,7 @@ REWORK_PLAN.md.
### FEAT-2: upgrade app internal logs to be parseable ### FEAT-2: upgrade app internal logs to be parseable
- Goal: combat logs in Firestore store enough structured state to run - Goal: combat logs in Firestore store enough structured state to run
skip/rotation analysis on ANY historic round not just replay stdout. skip/rotation analysis on ANY historic round --- not just replay stdout.
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must - Current logs: `{timestamp, message, encounterName, undo}`. Parser must
guess roster from message strings. Brittle. guess roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in - Upgrade: add structured fields at turn-state mutation log sites in
@@ -104,12 +110,12 @@ REWORK_PLAN.md.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows - Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused). encounter (not paused).
### BUG-5: mid-round addParticipant/revive corrupts rotation FIXED ### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core - Fixed (commit `494327f`). Slot-array turn order + DRY advance core
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate. `nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
- 500-round replay: 0 skips, 0 double-acts. - 500-round replay: 0 skips, 0 double-acts.
### BUG-6: reorderParticipants doesn't update turnOrderIds FIXED ### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds = - Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
participants.map(id) always. reorder cross-init allowed (DM override). participants.map(id) always. reorder cross-init allowed (DM override).
Display === rotation by construction. Display === rotation by construction.
@@ -181,8 +187,8 @@ REWORK_PLAN.md.
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites - [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites
- [x] BUG-5: fixed (1-list model, 500 rounds clean) - [x] BUG-5: fixed (1-list model, 500 rounds clean)
- [x] BUG-6: fixed structurally (1-list model) - [x] BUG-6: fixed structurally (1-list model)
- [x] BUG-12: fixed campaign selection follows activeDisplay - [x] BUG-12: fixed --- campaign selection follows activeDisplay
- [x] BUG-15: fixed DisplayView no longer re-sorts (drag order preserved) - [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN) - [x] BUG-8: ws adapter reconnect (implemented + GREEN)
- [ ] BUG-10: deact+reactivate double-act - [ ] BUG-10: deact+reactivate double-act
- [ ] BUG-11: FE Combat.scenario crash - [ ] BUG-11: FE Combat.scenario crash
+86 -18
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee Maximize2, Minimize2, Moon, Coffee, Clock
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -781,6 +781,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [manualInitiative, setManualInitiative] = useState('');
const [isNpc, setIsNpc] = useState(false); const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({}); const [hpChangeValues, setHpChangeValues] = useState({});
@@ -817,6 +818,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
let modifier = 0; let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP; let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false; let participantIsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
if (participantType === 'character') { if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId); const character = campaignCharacters.find(c => c.id === selectedCharacterId);
@@ -836,13 +839,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
participantIsNpc = isNpc; participantIsNpc = isNpc;
} }
const finalInitiative = initiativeRoll + modifier; const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
const newParticipant = { const newParticipant = {
id: generateId(), id: generateId(),
name: nameToAdd, name: nameToAdd,
type: participantType, type: participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null, originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: finalInitiative, initiative: computedInitiative,
maxHp: currentMaxHp, maxHp: currentMaxHp,
currentHp: currentMaxHp, currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false, isNpc: participantType === 'monster' ? participantIsNpc : false,
@@ -856,17 +859,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await storage.updateDoc(encounterPath, { await storage.updateDoc(encounterPath, {
participants: [...participants, newParticipant] participants: [...participants, newParticipant]
}); });
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, { logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { participants: [...participants] }, updates: { participants: [...participants] },
}); });
setLastRollDetails({ setLastRollDetails({
name: nameToAdd, name: nameToAdd,
roll: initiativeRoll, roll: manualInit ? null : initiativeRoll,
mod: modifier, mod: manualInit ? null : modifier,
total: finalInitiative, total: computedInitiative,
type: participantIsNpc ? 'NPC' : participantType type: participantIsNpc ? 'NPC' : participantType,
manual: manualInit,
}); });
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
@@ -876,6 +880,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
setSelectedCharacterId(''); setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false); setIsNpc(false);
setManualInitiative('');
} catch (err) { } catch (err) {
console.error("Error adding participant:", err); console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again."); alert("Failed to add participant. Please try again.");
@@ -944,6 +949,24 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
// Inline initiative edit (FEAT-3): blur/Enter commits. Re-syncs turnOrderIds.
const handleInlineInitiative = async (participantId, value) => {
if (!db) return;
const n = parseInt(value, 10);
if (isNaN(n)) return;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, initiative: n } : p
);
try {
await storage.updateDoc(encounterPath, {
participants: updatedParticipants,
...(encounter.isStarted ? syncTurnOrder(updatedParticipants) : {}),
});
} catch (err) {
console.error("Error updating initiative:", err);
}
};
const requestDeleteParticipant = (participantId, participantName) => { const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName }); setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
@@ -1307,6 +1330,19 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 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" className="mt-1 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>
<div className="md:col-span-2">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="manualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 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 className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP Max HP
@@ -1358,6 +1394,19 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 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" className="mt-1 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>
<div className="md:col-span-2">
<label htmlFor="charManualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="charManualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 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>
</> </>
)} )}
@@ -1375,8 +1424,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
{lastRollDetails && ( {lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center"> <p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}) {lastRollDetails.manual
: Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p> </p>
)} )}
@@ -1422,9 +1473,26 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
)} )}
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>} {isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
</p> </p>
<p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'}`}> <div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp} <span className="inline-flex items-center gap-1">
</p> <label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
<input
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
min="0"
max="99"
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600"
aria-label={`Initiative for ${p.name}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
{/* Death Saves - only player characters make death saving throws */} {/* Death Saves - only player characters make death saving throws */}
{isDead && encounter.isStarted && p.type === 'character' && ( {isDead && encounter.isStarted && p.type === 'character' && (
@@ -2283,12 +2351,12 @@ function AdminView({ userId }) {
<span className="inline-flex items-center"> <span className="inline-flex items-center">
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters <Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
</span> </span>
{campaign.createdAt && (
<span className="inline-flex items-center opacity-80">
{new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</span>
)}
</div> </div>
{campaign.createdAt && (
<div className="text-xs text-stone-300 opacity-70 mt-1">
<Clock size={12} className="mr-1 inline-block" /> Created: {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</div>
)}
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
+57
View File
@@ -0,0 +1,57 @@
// RED test: FEAT-3 initiative field on add participant.
// If initiative field set, use it (no roll). Empty = roll d20+mod (current).
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } from './testHelpers';
import { fireEvent } from '@testing-library/react';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantUpdate(name) {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants && last.data.participants.find(p => p.name === name);
}
describe('FEAT-3: initiative field on add (optional, empty=roll)', () => {
test('initiative field set → uses value, no roll', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Goblin' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '2' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '7' } });
// set explicit initiative
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: '15' } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Goblin'));
const p = lastParticipantUpdate('Goblin');
expect(p.initiative).toBe(15);
});
test('initiative field empty → rolls d20+mod', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Wolf' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '11' } });
// leave initiative empty
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Wolf'));
const p = lastParticipantUpdate('Wolf');
// rolled d20 (1-20) + mod 3 = range 4-23
expect(p.initiative).toBeGreaterThanOrEqual(4);
expect(p.initiative).toBeLessThanOrEqual(23);
});
});