Single source of truth: combat logic, storage parity, slot ordering #3
@@ -45,7 +45,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
|||||||
Old single-counter broken (successes missing, treated saves as fails).
|
Old single-counter broken (successes missing, treated saves as fails).
|
||||||
Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row.
|
Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row.
|
||||||
|
|
||||||
### Custom condition field
|
### Custom conditions field (DONE)
|
||||||
|
- FIXED — per-campaign freeform conditions. Input field in picker → persists to
|
||||||
|
`campaigns/{id}.customConditions[]`. Merged with built-ins at render.
|
||||||
|
ParticipantManager subscribes campaign doc. DisplayView renders custom as
|
||||||
|
raw label. toggleCondition unchanged (any string id). Dedup case-insensitive.
|
||||||
|
Enter or button to add. maxLength 40.
|
||||||
- Conditions hardcoded list. No way for DM to add custom.
|
- Conditions hardcoded list. No way for DM to add custom.
|
||||||
|
|
||||||
### Test integrated timeouts
|
### Test integrated timeouts
|
||||||
|
|||||||
+72
-6
@@ -797,7 +797,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
// PARTICIPANT MANAGER COMPONENT
|
// PARTICIPANT MANAGER COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
|
||||||
const [participantName, setParticipantName] = useState('');
|
const [participantName, setParticipantName] = useState('');
|
||||||
const [participantType, setParticipantType] = useState('monster');
|
const [participantType, setParticipantType] = useState('monster');
|
||||||
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
||||||
@@ -812,6 +812,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
const [lastRollDetails, setLastRollDetails] = useState(null);
|
const [lastRollDetails, setLastRollDetails] = useState(null);
|
||||||
const [openConditionsId, setOpenConditionsId] = useState(null);
|
const [openConditionsId, setOpenConditionsId] = useState(null);
|
||||||
|
const [customConditionInput, setCustomConditionInput] = useState('');
|
||||||
|
|
||||||
|
// Per-campaign custom conditions (DM-added freeform strings).
|
||||||
|
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
|
||||||
|
const customConditions = (campaignDoc && campaignDoc.customConditions) || [];
|
||||||
|
// Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }.
|
||||||
|
// id may contain spaces/emoji — toggleCondition stores raw string.
|
||||||
|
const allConditions = [
|
||||||
|
...CONDITIONS,
|
||||||
|
...customConditions.map(label => ({ id: label, label, emoji: '' })),
|
||||||
|
];
|
||||||
|
|
||||||
const participants = encounter.participants || [];
|
const participants = encounter.participants || [];
|
||||||
|
|
||||||
@@ -1078,8 +1089,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
try {
|
try {
|
||||||
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
|
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
const cond = CONDITIONS.find(c => c.id === conditionId);
|
const cond = allConditions.find(c => c.id === conditionId);
|
||||||
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
|
||||||
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
|
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: log.undo,
|
updates: log.undo,
|
||||||
@@ -1089,6 +1100,22 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Persist new freeform condition to campaign doc. Persists across encounters.
|
||||||
|
const addCustomCondition = async (label) => {
|
||||||
|
const trimmed = (label || '').trim();
|
||||||
|
if (!trimmed || !campaignId) return;
|
||||||
|
// dedupe against built-ins + existing custom (case-insensitive)
|
||||||
|
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
|
||||||
|
if (exists) { setCustomConditionInput(''); return; }
|
||||||
|
const next = [...customConditions, trimmed];
|
||||||
|
try {
|
||||||
|
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: next });
|
||||||
|
setCustomConditionInput('');
|
||||||
|
} catch (err) {
|
||||||
|
// fall through silently
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (e, id) => {
|
const handleDragStart = (e, id) => {
|
||||||
setDraggedItemId(id);
|
setDraggedItemId(id);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -1426,7 +1453,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
>
|
>
|
||||||
{cond.emoji} {cond.label}
|
{cond.emoji} {cond.label}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : (
|
||||||
|
<span
|
||||||
|
key={cId}
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-purple-900 border border-purple-600 text-xs text-purple-200 cursor-pointer hover:bg-purple-800"
|
||||||
|
title={`Remove ${cId}`}
|
||||||
|
onClick={() => toggleCondition(p.id, cId)}
|
||||||
|
>
|
||||||
|
{cId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1436,7 +1472,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
<div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600">
|
<div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600">
|
||||||
<p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p>
|
<p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{CONDITIONS.map(cond => {
|
{allConditions.map(cond => {
|
||||||
const active = (p.conditions || []).includes(cond.id);
|
const active = (p.conditions || []).includes(cond.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1450,6 +1486,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Add custom condition (per-campaign, freeform string) */}
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customConditionInput}
|
||||||
|
onChange={(e) => setCustomConditionInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput); }
|
||||||
|
}}
|
||||||
|
placeholder="Add custom condition..."
|
||||||
|
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addCustomCondition(customConditionInput)}
|
||||||
|
disabled={!customConditionInput.trim()}
|
||||||
|
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1960,6 +2018,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
encounter={selectedEncounter}
|
encounter={selectedEncounter}
|
||||||
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
|
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
|
||||||
campaignCharacters={campaignCharacters}
|
campaignCharacters={campaignCharacters}
|
||||||
|
campaignId={campaignId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2522,7 +2581,14 @@ function DisplayView() {
|
|||||||
>
|
>
|
||||||
{cond.emoji} {cond.label}
|
{cond.emoji} {cond.label}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : (
|
||||||
|
<span
|
||||||
|
key={cId}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-900 border border-purple-500 text-xs md:text-sm text-purple-200 font-medium"
|
||||||
|
>
|
||||||
|
{cId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user