// Mythic Oracle — NPC tracker import { getActiveCampaign } from './app.js'; import { getNpcs, createNpc, updateNpc, deleteNpc } from './api.js'; const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.'; const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing']; const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly']; const SECTIONS = [ { title: 'Identity', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'appearance', label: 'Appearance', type: 'textarea' }, { key: 'age', label: 'Age', type: 'text' }, { key: 'gender', label: 'Gender', type: 'text' }, { key: 'pronouns', label: 'Pronouns', type: 'text' }, { key: 'voice', label: 'Voice', type: 'text' }, { key: 'distinguishing_features', label: 'Distinguishing Features', type: 'textarea' }, ], }, { title: 'Social', fields: [ { key: 'faction', label: 'Faction', type: 'text' }, { key: 'occupation', label: 'Occupation', type: 'text' }, { key: 'social_status', label: 'Social Status', type: 'text' }, { key: 'relationship_to_pc', label: 'Relationship to PC', type: 'text' }, { key: 'loyalty', label: 'Loyalty', type: 'text' }, ], }, { title: 'Personality', fields: [ { key: 'personality_traits', label: 'Personality Traits', type: 'textarea' }, { key: 'fears', label: 'Fears', type: 'textarea' }, { key: 'desires', label: 'Desires', type: 'textarea' }, { key: 'secrets', label: 'Secrets', type: 'textarea' }, { key: 'motivations', label: 'Motivations', type: 'textarea' }, ], }, { title: 'Narrative', fields: [ { key: 'first_encountered', label: 'First Encountered', type: 'text' }, { key: 'last_seen', label: 'Last Seen', type: 'text' }, { key: 'current_location', label: 'Current Location', type: 'text' }, { key: 'current_goal', label: 'Current Goal', type: 'textarea' }, { key: 'role_in_threads', label: 'Role in Threads', type: 'textarea' }, ], }, { title: 'Status', fields: [ { key: 'alive_status', label: 'Alive Status', type: 'select' }, { key: 'disposition', label: 'Disposition', type: 'disposition' }, ], }, ]; const NOTES_FIELD = { key: 'notes', label: 'Notes', type: 'textarea' }; const ALL_FIELDS = SECTIONS.flatMap((section) => section.fields).concat(NOTES_FIELD); const container = document.getElementById('view-npcs'); let npcsCache = []; let aliveFilter = 'all'; let dispositionFilter = 'all'; let expandedId = null; let editingNpcId = null; function statusLabel(value) { return value.charAt(0).toUpperCase() + value.slice(1); } function dispositionDisplay(npc) { return npc.disposition || 'Unknown'; } function dispositionClass(npc) { return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`; } function renderNoCampaign() { container.innerHTML = ''; const msg = document.createElement('div'); msg.className = 'npcs-empty-state'; msg.textContent = NO_CAMPAIGN_MESSAGE; container.appendChild(msg); container.dataset.skeleton = 'false'; } function ensureSkeleton() { if (container.dataset.skeleton === 'true') return; container.innerHTML = `

NPCs

`; container.dataset.skeleton = 'true'; bindSkeletonEvents(); } function bindSkeletonEvents() { container.querySelector('#npcCreateBtn').addEventListener('click', openCreateModal); container.querySelectorAll('.npc-tab').forEach((tab) => { tab.addEventListener('click', () => { if (tab.dataset.alive === aliveFilter) return; aliveFilter = tab.dataset.alive; container.querySelectorAll('.npc-tab').forEach((t) => t.classList.toggle('active', t === tab)); expandedId = null; renderList(); }); }); container.querySelector('#npcDispositionFilter').addEventListener('change', (event) => { dispositionFilter = event.target.value; expandedId = null; renderList(); }); container.querySelector('#npcCancelBtn').addEventListener('click', closeModal); container.querySelector('#npcSaveBtn').addEventListener('click', saveModal); container.querySelector('#npcModalOverlay').addEventListener('click', (event) => { if (event.target.id === 'npcModalOverlay') closeModal(); }); } function renderDispositionOptions() { const select = container.querySelector('#npcDispositionFilter'); const distinct = [...new Set(npcsCache.map((npc) => dispositionDisplay(npc)))].sort((a, b) => a.localeCompare(b) ); select.innerHTML = ''; const allOption = document.createElement('option'); allOption.value = 'all'; allOption.textContent = 'All Dispositions'; select.appendChild(allOption); distinct.forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value; select.appendChild(option); }); if (dispositionFilter !== 'all' && !distinct.includes(dispositionFilter)) { dispositionFilter = 'all'; } select.value = dispositionFilter; } function buildEmptyMessage() { const aliveText = aliveFilter === 'all' ? '' : `${statusLabel(aliveFilter)} `; if (dispositionFilter === 'all') { return `No ${aliveText}NPCs.`; } return `No ${aliveText}NPCs with disposition "${dispositionFilter}".`; } function renderList() { const listEl = container.querySelector('#npcList'); listEl.innerHTML = ''; const filtered = npcsCache.filter((npc) => { const matchesAlive = aliveFilter === 'all' || npc.alive_status === aliveFilter; const matchesDisposition = dispositionFilter === 'all' || dispositionDisplay(npc) === dispositionFilter; return matchesAlive && matchesDisposition; }); if (filtered.length === 0) { const empty = document.createElement('div'); empty.className = 'npc-empty'; empty.textContent = npcsCache.length === 0 ? 'No NPCs yet.' : buildEmptyMessage(); listEl.appendChild(empty); return; } filtered.forEach((npc) => { listEl.appendChild(buildNpcCard(npc)); }); } function buildNpcCard(npc) { const card = document.createElement('div'); card.className = 'npc-card'; const header = document.createElement('button'); header.type = 'button'; header.className = 'npc-card-header'; const nameSpan = document.createElement('span'); nameSpan.className = 'npc-card-name'; nameSpan.textContent = npc.name; const badges = document.createElement('span'); badges.className = 'npc-card-badges'; const dispositionBadge = document.createElement('span'); dispositionBadge.className = dispositionClass(npc); dispositionBadge.textContent = dispositionDisplay(npc); const aliveBadge = document.createElement('span'); aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`; aliveBadge.textContent = statusLabel(npc.alive_status); badges.append(dispositionBadge, aliveBadge); header.append(nameSpan, badges); header.addEventListener('click', () => { expandedId = expandedId === npc.id ? null : npc.id; renderList(); }); card.appendChild(header); if (expandedId === npc.id) { card.classList.add('expanded'); card.appendChild(buildNpcDetail(npc, card)); } return card; } function buildNpcDetail(npc, card) { const detail = document.createElement('div'); detail.className = 'npc-card-detail'; const statusSection = SECTIONS.find((section) => section.title === 'Status'); const orderedSections = [statusSection, ...SECTIONS.filter((section) => section !== statusSection)]; orderedSections.forEach((section) => { const sectionEl = document.createElement('div'); sectionEl.className = 'npc-section'; const titleEl = document.createElement('div'); titleEl.className = 'npc-section-title'; titleEl.textContent = section.title; sectionEl.appendChild(titleEl); section.fields.forEach(({ key, label }) => { if (key === 'alive_status') { sectionEl.appendChild(buildAliveStatusButtonRow(npc, card)); return; } if (key === 'disposition') { sectionEl.appendChild(buildDispositionButtonRow(npc, card)); return; } sectionEl.appendChild(buildFieldRow(label, npc[key])); }); detail.appendChild(sectionEl); }); const notesSection = document.createElement('div'); notesSection.className = 'npc-section'; const notesTitle = document.createElement('div'); notesTitle.className = 'npc-section-title'; notesTitle.textContent = NOTES_FIELD.label; notesSection.appendChild(notesTitle); notesSection.appendChild(buildFieldRow(null, npc.notes, true)); detail.appendChild(notesSection); const actions = document.createElement('div'); actions.className = 'npc-card-actions'; const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'npc-edit-btn'; editBtn.textContent = 'Edit'; editBtn.addEventListener('click', () => openEditModal(npc)); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'npc-delete-btn'; deleteBtn.textContent = 'Delete'; deleteBtn.addEventListener('click', () => handleDelete(npc)); actions.append(editBtn, deleteBtn); detail.appendChild(actions); return detail; } function buildFieldRow(label, value, valueOnly = false) { const row = document.createElement('div'); row.className = 'npc-field'; if (!valueOnly) { const labelEl = document.createElement('span'); labelEl.className = 'npc-field-label'; labelEl.textContent = label; row.appendChild(labelEl); } const valueEl = document.createElement('span'); valueEl.className = 'npc-field-value'; valueEl.textContent = value || '—'; row.appendChild(valueEl); return row; } function buildAliveStatusButtonRow(npc, card) { const row = document.createElement('div'); row.className = 'npc-field'; const labelEl = document.createElement('span'); labelEl.className = 'npc-field-label'; labelEl.textContent = 'Alive Status'; row.appendChild(labelEl); const buttonRow = document.createElement('div'); buttonRow.className = 'npc-status-buttons'; ALIVE_STATUSES.forEach((status) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'npc-status-btn'; btn.dataset.value = status; btn.classList.toggle('active', status === npc.alive_status); btn.textContent = statusLabel(status); btn.addEventListener('click', () => handleAliveStatusChange(npc, status, card)); buttonRow.appendChild(btn); }); row.appendChild(buttonRow); return row; } function buildDispositionButtonRow(npc, card) { const row = document.createElement('div'); row.className = 'npc-field'; const labelEl = document.createElement('span'); labelEl.className = 'npc-field-label'; labelEl.textContent = 'Disposition'; row.appendChild(labelEl); const buttonRow = document.createElement('div'); buttonRow.className = 'npc-disposition-buttons'; DISPOSITIONS.forEach((value) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `npc-disposition-btn npc-disposition-btn-${value}`; btn.dataset.value = value; btn.classList.toggle('active', value === npc.disposition); btn.textContent = statusLabel(value); btn.addEventListener('click', () => handleDispositionChange(npc, value, card)); buttonRow.appendChild(btn); }); row.appendChild(buttonRow); return row; } async function handleAliveStatusChange(npc, newStatus, card) { if (newStatus === npc.alive_status) return; const campaign = getActiveCampaign(); if (!campaign) return; const payload = {}; ALL_FIELDS.forEach(({ key }) => { payload[key] = npc[key]; }); payload.alive_status = newStatus; try { const updated = await updateNpc(campaign.id, npc.id, payload); Object.assign(npc, updated); updateCardStatusUI(card, npc); } catch (err) { alert(err.message); } } async function handleDispositionChange(npc, newDisposition, card) { if (newDisposition === npc.disposition) return; const campaign = getActiveCampaign(); if (!campaign) return; const payload = {}; ALL_FIELDS.forEach(({ key }) => { payload[key] = npc[key]; }); payload.disposition = newDisposition; try { const updated = await updateNpc(campaign.id, npc.id, payload); Object.assign(npc, updated); updateCardStatusUI(card, npc); } catch (err) { alert(err.message); } } function updateCardStatusUI(card, npc) { const aliveBadge = card.querySelector('.npc-status-badge'); if (aliveBadge) { aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`; aliveBadge.textContent = statusLabel(npc.alive_status); } const dispositionBadge = card.querySelector('.npc-disposition-badge'); if (dispositionBadge) { dispositionBadge.className = dispositionClass(npc); dispositionBadge.textContent = dispositionDisplay(npc); } card.querySelectorAll('.npc-status-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.value === npc.alive_status); }); card.querySelectorAll('.npc-disposition-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.value === npc.disposition); }); } function buildModalForm(values) { const body = container.querySelector('#npcModalBody'); body.innerHTML = ''; const identitySection = SECTIONS.find((section) => section.title === 'Identity'); const statusSection = SECTIONS.find((section) => section.title === 'Status'); const [nameField, ...restOfIdentityFields] = identitySection.fields; body.appendChild(buildModalField(nameField, values)); body.appendChild(buildModalSectionTitle(statusSection.title)); statusSection.fields.forEach((field) => { body.appendChild(buildModalField(field, values)); }); body.appendChild(buildModalSectionTitle(identitySection.title)); restOfIdentityFields.forEach((field) => { body.appendChild(buildModalField(field, values)); }); SECTIONS.filter((section) => section !== identitySection && section !== statusSection).forEach((section) => { body.appendChild(buildModalSectionTitle(section.title)); section.fields.forEach((field) => { body.appendChild(buildModalField(field, values)); }); }); body.appendChild(buildModalSectionTitle(NOTES_FIELD.label)); body.appendChild(buildModalField(NOTES_FIELD, values, true)); } function buildModalSectionTitle(title) { const titleEl = document.createElement('div'); titleEl.className = 'modal-section-title'; titleEl.textContent = title; return titleEl; } function buildModalField({ key, label, type }, values, hideLabel = false) { const group = document.createElement('div'); group.className = 'modal-field'; const labelEl = hideLabel ? null : document.createElement('label'); if (labelEl) { labelEl.className = 'modal-field-label'; labelEl.textContent = label; group.appendChild(labelEl); } if (type === 'select') { group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive')); return group; } if (type === 'disposition') { group.appendChild( buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn') ); return group; } if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`); let input; if (type === 'textarea') { input = document.createElement('textarea'); input.className = 'modal-textarea'; input.rows = 3; input.value = values[key] || ''; } else { input = document.createElement('input'); input.type = 'text'; input.className = 'modal-input'; input.value = values[key] || ''; } input.id = `npc-field-${key}`; group.appendChild(input); return group; } function buildModalButtonGroup(inputId, options, currentValue, valueClassPrefix) { const wrapper = document.createElement('div'); wrapper.className = 'modal-status-buttons'; const hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; hiddenInput.id = inputId; hiddenInput.value = currentValue; wrapper.appendChild(hiddenInput); options.forEach((value) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = valueClassPrefix ? `modal-status-btn ${valueClassPrefix}-${value}` : 'modal-status-btn'; btn.dataset.value = value; btn.classList.toggle('active', value === currentValue); btn.textContent = statusLabel(value); btn.addEventListener('click', () => { hiddenInput.value = value; wrapper.querySelectorAll('.modal-status-btn').forEach((b) => { b.classList.toggle('active', b.dataset.value === value); }); }); wrapper.appendChild(btn); }); return wrapper; } function collectFormData() { const data = {}; ALL_FIELDS.forEach(({ key }) => { const el = container.querySelector(`#npc-field-${key}`); const value = el.value; data[key] = value.trim() === '' ? null : value; }); return data; } function openCreateModal() { editingNpcId = null; container.querySelector('#npcModalTitle').textContent = 'New NPC'; buildModalForm({}); showModal(); } function openEditModal(npc) { editingNpcId = npc.id; container.querySelector('#npcModalTitle').textContent = 'Edit NPC'; buildModalForm(npc); showModal(); } function showModal() { container.querySelector('#npcModalOverlay').classList.add('open'); } function closeModal() { container.querySelector('#npcModalOverlay').classList.remove('open'); } async function saveModal() { const campaign = getActiveCampaign(); if (!campaign) return; const data = collectFormData(); if (!data.name) { alert('Name is required.'); return; } if (!data.alive_status) data.alive_status = 'alive'; if (!data.disposition) data.disposition = 'unknown'; try { if (editingNpcId === null) { await createNpc(campaign.id, data); } else { await updateNpc(campaign.id, editingNpcId, data); } closeModal(); await loadAndRender(); } catch (err) { alert(err.message); } } async function handleDelete(npc) { const confirmed = confirm(`Delete "${npc.name}"? This cannot be undone.`); if (!confirmed) return; const campaign = getActiveCampaign(); if (!campaign) return; await deleteNpc(campaign.id, npc.id); expandedId = null; await loadAndRender(); } async function loadAndRender() { const campaign = getActiveCampaign(); if (!campaign) { renderNoCampaign(); return; } ensureSkeleton(); npcsCache = await getNpcs(campaign.id); renderDispositionOptions(); renderList(); } function init() { loadAndRender(); const navBtn = document.querySelector('.nav-item[data-view="npcs"]'); if (navBtn) { navBtn.addEventListener('click', () => loadAndRender()); } } document.addEventListener('DOMContentLoaded', init);