// 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 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: 'text' },
],
},
];
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 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 = `
`;
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 = 'npc-disposition-badge';
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));
}
return card;
}
function buildNpcDetail(npc) {
const detail = document.createElement('div');
detail.className = 'npc-card-detail';
SECTIONS.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 }) => {
sectionEl.appendChild(buildFieldRow(label, key === 'alive_status' ? statusLabel(npc.alive_status) : 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 buildModalForm(values) {
const body = container.querySelector('#npcModalBody');
body.innerHTML = '';
SECTIONS.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';
if (!hideLabel) {
const labelEl = document.createElement('label');
labelEl.className = 'modal-field-label';
labelEl.textContent = label;
labelEl.setAttribute('for', `npc-field-${key}`);
group.appendChild(labelEl);
}
let input;
if (type === 'select') {
input = document.createElement('select');
input.className = 'modal-select';
ALIVE_STATUSES.forEach((status) => {
const option = document.createElement('option');
option.value = status;
option.textContent = statusLabel(status);
input.appendChild(option);
});
input.value = values.alive_status || 'alive';
} else 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 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);