// 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 = `
`;
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);