feat: custom conditions per campaign
DM can add freeform custom conditions alongside built-ins. Persists to
campaign doc, survives across encounters, shared with display view.
Implementation:
- ParticipantManager subscribes campaign doc via useFirestoreDocument
- customConditions[] merged with built-in CONDITIONS at render
- Input field + Add button in conditions picker (Enter or click)
- Dedup case-insensitive vs built-ins + existing custom
- maxLength 40
- addCustomCondition writes to campaigns/{id}.customConditions
- Badge render: custom conditions show raw label (no emoji)
- DisplayView: same fallback render for custom ids
- toggleCondition unchanged (already accepts any string id)
- campaignId prop passed from EncounterManager -> ParticipantManager
243 tests green. Build clean.
This commit is contained in:
@@ -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 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.
|
||||
|
||||
### Test integrated timeouts
|
||||
|
||||
+72
-6
@@ -797,7 +797,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
||||
// PARTICIPANT MANAGER COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
|
||||
const [participantName, setParticipantName] = useState('');
|
||||
const [participantType, setParticipantType] = useState('monster');
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState('');
|
||||
@@ -812,6 +812,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [lastRollDetails, setLastRollDetails] = 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 || [];
|
||||
|
||||
@@ -1078,8 +1089,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
try {
|
||||
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
const cond = CONDITIONS.find(c => c.id === conditionId);
|
||||
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
||||
const cond = allConditions.find(c => c.id === conditionId);
|
||||
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
|
||||
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
|
||||
encounterPath,
|
||||
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) => {
|
||||
setDraggedItemId(id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
@@ -1426,7 +1453,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
>
|
||||
{cond.emoji} {cond.label}
|
||||
</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>
|
||||
)}
|
||||
@@ -1436,7 +1472,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CONDITIONS.map(cond => {
|
||||
{allConditions.map(cond => {
|
||||
const active = (p.conditions || []).includes(cond.id);
|
||||
return (
|
||||
<button
|
||||
@@ -1450,6 +1486,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
@@ -1960,6 +2018,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
encounter={selectedEncounter}
|
||||
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
|
||||
campaignCharacters={campaignCharacters}
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2522,7 +2581,14 @@ function DisplayView() {
|
||||
>
|
||||
{cond.emoji} {cond.label}
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user