From 863e8b3719c8878c948d0b25d02a1cc7991d922a Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:23:19 -0400 Subject: [PATCH] Add AC field (character, monster/npc, participant edit); layout polish AC (Armor Class) optional field across all participant entry points: - shared: ac field on makeParticipant + buildMonsterParticipant + buildCharacterParticipant, defaults null - CharacterManager: defaultAc state + add form field + inline edit field + display in character list - Monster add form: AC field - EditParticipantModal: AC field next to Initiative - ParticipantManager (DM list): AC badge on name row (sky-blue, stylized, large value, small label) for at-a-glance reading - Player display: no AC (DM only) Layout polish: - Add participants form: 12-col grid, 5 fields single row (Init Mod, Initiative, AC, Max HP, HP Formula), shrunk from oversized fields - Character add form: 12-col grid, name grows (col-span-6), Init Mod/AC/HP small right-aligned, order matches add participants - Character inline edit: labels added (Name/HP/Init Mod/AC), name flex-grows - HP Formula: label trimmed (example moved to placeholder 'e.g. 2d6+9'), Reroll button in edit modal - ParticipantManager init input shrunk (w-8, centered) Tests: 6 new AC builder tests (turn.ac.test.js). Existing test labels updated for renamed fields. --- TODO.md | 5 +- shared/tests/turn.ac.test.js | 35 +++++ shared/turn.js | 5 +- src/App.js | 187 ++++++++++++++++-------- src/tests/App.characterization.test.js | 2 +- src/tests/Logs.characterization.test.js | 2 +- 6 files changed, 172 insertions(+), 64 deletions(-) create mode 100644 shared/tests/turn.ac.test.js diff --git a/TODO.md b/TODO.md index f04ce7a..cbd8a64 100644 --- a/TODO.md +++ b/TODO.md @@ -9,12 +9,13 @@ fullscreen and dont lock on main app dm view and the no-game-player view also better vert tab layout - labelt friendly -needs AC for players dude +x needs AC for players dude and quick entry hp hp do not carry from encounter to ecnounter!!! - +^feature to add back to campaign character after encounter? +maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront) hp wont go over max and no temp hp support diff --git a/shared/tests/turn.ac.test.js b/shared/tests/turn.ac.test.js new file mode 100644 index 0000000..4ae1ab8 --- /dev/null +++ b/shared/tests/turn.ac.test.js @@ -0,0 +1,35 @@ +// AC field on participant + builders. +const shared = require('@ttrpg/shared'); +const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared; + +describe('AC field', () => { + test('makeParticipant accepts ac', () => { + const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 }); + expect(p.ac).toBe(13); + }); + + test('makeParticipant ac defaults null', () => { + const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 }); + expect(p.ac).toBeNull(); + }); + + test('buildMonsterParticipant accepts ac', () => { + const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 }); + expect(participant.ac).toBe(15); + }); + + test('buildMonsterParticipant ac defaults null', () => { + const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 }); + expect(participant.ac).toBeNull(); + }); + + test('buildCharacterParticipant accepts character ac', () => { + const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 }); + expect(participant.ac).toBe(18); + }); + + test('buildCharacterParticipant ac defaults null when unset', () => { + const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 }); + expect(participant.ac).toBeNull(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 1046286..d2a1672 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -361,6 +361,7 @@ function makeParticipant(opts) { deathSaveSuccesses: opts.deathSaveSuccesses || 0, deathSaveFailures: opts.deathSaveFailures || 0, hpFormula: opts.hpFormula || null, + ac: opts.ac !== undefined ? opts.ac : null, }; } @@ -377,12 +378,13 @@ function buildCharacterParticipant(character) { initiative: finalInitiative, maxHp, currentHp: maxHp, + ac: character.defaultAc !== undefined ? character.defaultAc : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } -function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) { +function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) { const initiativeRoll = rollD20(); const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const finalInitiative = initiativeRoll + modifier; @@ -406,6 +408,7 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) { maxHp: hp, currentHp: hp, hpFormula: hpFormula || null, + ac: ac !== undefined ? ac : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll }, }; diff --git a/src/App.js b/src/App.js index 07a2559..7d10402 100644 --- a/src/App.js +++ b/src/App.js @@ -583,6 +583,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [currentHp, setCurrentHp] = useState(participant.currentHp); const [maxHp, setMaxHp] = useState(participant.maxHp); const [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); + const [ac, setAc] = useState(participant.ac != null ? participant.ac : ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { @@ -602,6 +603,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { currentHp: finalCurrentHp, maxHp: finalMaxHp, hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null, + ac: ac !== '' ? parseInt(ac, 10) : null, type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -618,15 +620,27 @@ function EditParticipantModal({ participant, onClose, onSave }) { 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" /> -
- - setInitiative(e.target.value)} - 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" - /> +
+
+ + setInitiative(e.target.value)} + 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" + /> +
+
+ + setAc(e.target.value)} + placeholder="optional" + 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" + /> +
@@ -656,7 +670,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { type="text" value={hpFormula} onChange={(e) => setHpFormula(e.target.value)} - placeholder="2d6+9" + placeholder="e.g. 2d6+9" className="flex-1 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" /> @@ -914,31 +943,51 @@ function CharacterManager({ campaignId, campaignCharacters }) { character.id, editingCharacter.name, editingCharacter.defaultMaxHp, - editingCharacter.defaultInitMod + editingCharacter.defaultInitMod, + editingCharacter.defaultAc ); }} className="flex-grow flex flex-wrap gap-2 items-center" > - setEditingCharacter({ ...editingCharacter, name: e.target.value })} - className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - /> - setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Max HP" - /> - setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Init Mod" - /> +
+ + setEditingCharacter({ ...editingCharacter, name: e.target.value })} + className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Max HP" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Init Mod" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })} + className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="AC" + /> +
@@ -955,7 +1004,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { {character.name}{' '} - (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}) + (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
@@ -964,7 +1013,8 @@ function CharacterManager({ campaignId, campaignCharacters }) { id: character.id, name: character.name, defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP, - defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD + defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD, + defaultAc: character.defaultAc ?? '' })} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600" aria-label="Edit character" @@ -1013,6 +1063,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp const [maxHp, setMaxHp] = useState(''); const [hpFormula, setHpFormula] = useState(''); const [manualInitiative, setManualInitiative] = useState(''); + const [monsterAc, setMonsterAc] = useState(''); const [asNpc, setAsNpc] = useState(false); const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); @@ -1095,6 +1146,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp maxHp: currentMaxHp, currentHp: currentMaxHp, hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null, + ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null), conditions: [], isActive: true, status: 'conscious', @@ -1120,6 +1172,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp setHpFormula(''); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); + setMonsterAc(''); setAsNpc(false); setManualInitiative(''); } catch (err) { @@ -1411,7 +1464,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp 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" />
-
+
@@ -1423,7 +1476,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp 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" />
-
+
@@ -1436,7 +1489,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp 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" />
-
+
+ + setMonsterAc(e.target.value)} + 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" + /> +
+
@@ -1448,16 +1511,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp 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" />
-
+
setHpFormula(e.target.value)} - placeholder="2d6+9" + placeholder="e.g. 2d6+9" 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" />
@@ -1579,6 +1642,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp

{isZeroHp && ☠️} {p.name} ({participantDisplayType}) + {p.ac != null && ( + + AC + {p.ac} + + )} {isCurrentTurn && !encounter.isPaused && ( CURRENT @@ -1604,7 +1673,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp 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 disabled:opacity-50 disabled:cursor-not-allowed" + 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" aria-label={`Initiative for ${p.name}`} /> diff --git a/src/tests/App.characterization.test.js b/src/tests/App.characterization.test.js index 83d5f2a..93a64c1 100644 --- a/src/tests/App.characterization.test.js +++ b/src/tests/App.characterization.test.js @@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => { // CharacterManager form fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } }); - fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } }); + fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } }); fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } }); fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); diff --git a/src/tests/Logs.characterization.test.js b/src/tests/Logs.characterization.test.js index 6ddd0be..316cc7f 100644 --- a/src/tests/Logs.characterization.test.js +++ b/src/tests/Logs.characterization.test.js @@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) { await selectCampaignByName(`DS-${name}`); fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } }); - fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } }); + fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } }); fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1)); const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;