Phase 6 — NPC tracker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudecode
2026-07-01 18:13:38 -04:00
parent 68bc6b8810
commit 85adbbf084
8 changed files with 1078 additions and 10 deletions
+9
View File
@@ -384,6 +384,15 @@ These are final. Do not propose alternatives unless explicitly asked.
- Session logs are append-style with a date per entry.
- World and lore are single persistent documents per campaign
stored in campaign_docs with doc_type 'world' and 'lore'.
- Response envelope: { data } / { error } is the canonical API
response shape. campaigns.js uses raw JSON and is a known
exception to be reconciled in Phase 11.
- Route nesting: campaign-scoped resources use nested routes at
/api/campaigns/:campaignId/resource, mounted with
mergeParams: true.
- Scope rule: public/index.html and server/index.js are always
implicitly in scope when wiring a new feature view and do not
need explicit approval as scope expansions.
---
+317
View File
@@ -1173,3 +1173,320 @@ h1, h2, h3, h4, h5, h6 {
margin-top: 0;
flex: 1;
}
.modal-section-title {
margin: 4px 0 -4px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.modal-section-title:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
/* --- NPCs --- */
.npcs-empty-state {
max-width: 700px;
margin: 40px auto;
padding: 20px;
text-align: center;
font-style: italic;
color: var(--text-muted);
}
.npcs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 700px;
margin: 0 auto 16px;
}
.npcs-title {
margin: 0;
font-size: 1.4rem;
}
.npc-create-btn {
flex-shrink: 0;
padding: 10px 18px;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 3px;
color: var(--accent);
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease;
}
.npc-create-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-hover);
}
.npc-filters {
display: flex;
gap: 8px;
max-width: 700px;
margin: 0 auto 16px;
}
.npc-alive-tabs {
display: flex;
flex: 1;
gap: 8px;
}
.npc-tab {
flex: 1;
padding: 10px 4px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.npc-tab:hover {
color: var(--text-primary);
border-color: var(--accent-hover);
}
.npc-tab.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.npc-disposition-filter {
flex-shrink: 0;
width: 180px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 0.82rem;
cursor: pointer;
}
.npc-disposition-filter:focus {
outline: none;
border-color: var(--accent-hover);
}
.npc-list {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.npc-empty {
padding: 24px;
text-align: center;
font-style: italic;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
}
.npc-card {
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
overflow: hidden;
}
.npc-card.expanded {
border-color: var(--accent);
}
.npc-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 14px 18px;
border: none;
background: none;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 0.95rem;
text-align: left;
cursor: pointer;
}
.npc-card-header:hover {
background: var(--bg-hover);
}
.npc-card-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.npc-card-badges {
flex-shrink: 0;
display: flex;
gap: 6px;
}
.npc-status-badge,
.npc-disposition-badge {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 12px;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid transparent;
}
.npc-disposition-badge {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.npc-status-alive {
color: var(--success);
border-color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.npc-status-dead {
color: var(--danger);
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.npc-status-unknown {
color: var(--text-muted);
border-color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
}
.npc-status-missing {
color: var(--info);
border-color: var(--info);
background: color-mix(in srgb, var(--info) 12%, transparent);
}
.npc-card-detail {
border-top: 1px solid var(--border);
max-height: 480px;
overflow-y: auto;
}
.npc-section {
padding: 14px 18px;
border-top: 1px solid var(--border);
}
.npc-section:first-child {
border-top: none;
}
.npc-section-title {
margin-bottom: 8px;
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.npc-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-bottom: 1px solid var(--bg);
}
.npc-field:last-child {
border-bottom: none;
}
.npc-field-label {
font-family: var(--font-heading);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.npc-field-value {
font-size: 0.9rem;
color: var(--text-primary);
white-space: pre-wrap;
}
.npc-card-actions {
display: flex;
gap: 10px;
padding: 14px 18px 18px;
}
.npc-edit-btn,
.npc-delete-btn {
flex: 1;
padding: 10px;
border-radius: 3px;
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
background: var(--bg);
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease;
}
.npc-edit-btn {
border: 1px solid var(--accent);
color: var(--accent);
}
.npc-edit-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-hover);
}
.npc-delete-btn {
border: 1px solid var(--danger);
color: var(--danger);
}
.npc-delete-btn:hover {
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
}
+1
View File
@@ -307,4 +307,5 @@
<script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script>
<script src="js/threads.js" type="module"></script>
<script src="js/npcs.js" type="module"></script>
</html>
+26
View File
@@ -79,3 +79,29 @@ export async function deleteThread(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
return result.data;
}
export async function getNpcs(campaignId) {
const { data } = await request(`/campaigns/${campaignId}/npcs`);
return data;
}
export async function createNpc(campaignId, data) {
const result = await request(`/campaigns/${campaignId}/npcs`, {
method: 'POST',
body: JSON.stringify(data),
});
return result.data;
}
export async function updateNpc(campaignId, id, data) {
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteNpc(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, { method: 'DELETE' });
return result.data;
}
+471 -1
View File
@@ -1 +1,471 @@
// Mythic Oracle — NPCs
// 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 = `
<div class="npcs-header">
<h2 class="npcs-title">NPCs</h2>
<button class="npc-create-btn" id="npcCreateBtn" type="button">+ New NPC</button>
</div>
<div class="npc-filters">
<div class="npc-alive-tabs" id="npcAliveTabs">
<button class="npc-tab active" data-alive="all" type="button">All</button>
<button class="npc-tab" data-alive="alive" type="button">Alive</button>
<button class="npc-tab" data-alive="dead" type="button">Dead</button>
<button class="npc-tab" data-alive="unknown" type="button">Unknown</button>
<button class="npc-tab" data-alive="missing" type="button">Missing</button>
</div>
<select class="npc-disposition-filter" id="npcDispositionFilter">
<option value="all">All Dispositions</option>
</select>
</div>
<div class="npc-list" id="npcList"></div>
<div class="modal-overlay" id="npcModalOverlay">
<div class="modal">
<h3 class="modal-title" id="npcModalTitle">New NPC</h3>
<div class="modal-body" id="npcModalBody"></div>
<div class="modal-actions">
<button class="clear-btn" id="npcCancelBtn" type="button">Cancel</button>
<button class="roll-btn" id="npcSaveBtn" type="button">Save NPC</button>
</div>
</div>
</div>
`;
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);
+29 -7
View File
@@ -38,13 +38,35 @@ CREATE TABLE IF NOT EXISTS threads (
);
CREATE TABLE IF NOT EXISTS npcs (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
description TEXT,
notes TEXT,
motivations TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
description TEXT,
notes TEXT,
motivations TEXT,
appearance TEXT,
age TEXT,
gender TEXT,
pronouns TEXT,
voice TEXT,
distinguishing_features TEXT,
faction TEXT,
occupation TEXT,
social_status TEXT,
relationship_to_pc TEXT,
loyalty TEXT,
personality_traits TEXT,
fears TEXT,
desires TEXT,
secrets TEXT,
first_encountered TEXT,
last_seen TEXT,
current_location TEXT,
current_goal TEXT,
role_in_threads TEXT,
alive_status TEXT DEFAULT 'alive',
disposition TEXT DEFAULT 'unknown',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS session_logs (
+1 -1
View File
@@ -23,7 +23,7 @@ app.get('/health', (req, res) => {
app.use('/api/campaigns', campaignsRouter);
app.use('/api/characters', charactersRouter);
app.use('/api/campaigns/:campaignId/threads', threadsRouter);
app.use('/api/npcs', npcsRouter);
app.use('/api/campaigns/:campaignId/npcs', npcsRouter);
app.use('/api/notes', notesRouter);
app.use('/api/tables', tablesRouter);
app.use('/api/systems', systemsRouter);
+224 -1
View File
@@ -1,4 +1,227 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const router = express.Router({ mergeParams: true });
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
const OPTIONAL_TEXT_FIELDS = [
'description',
'notes',
'motivations',
'appearance',
'age',
'gender',
'pronouns',
'voice',
'distinguishing_features',
'faction',
'occupation',
'social_status',
'relationship_to_pc',
'loyalty',
'personality_traits',
'fears',
'desires',
'secrets',
'first_encountered',
'last_seen',
'current_location',
'current_goal',
'role_in_threads',
'disposition',
];
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
const listNpcsStmt = db.prepare('SELECT * FROM npcs WHERE campaign_id = ? ORDER BY created_at DESC');
const getNpcStmt = db.prepare('SELECT * FROM npcs WHERE id = ? AND campaign_id = ?');
const insertNpcStmt = db.prepare(`
INSERT INTO npcs (
campaign_id, name, description, notes, motivations, appearance, age,
gender, pronouns, voice, distinguishing_features, faction, occupation,
social_status, relationship_to_pc, loyalty, personality_traits, fears,
desires, secrets, first_encountered, last_seen, current_location,
current_goal, role_in_threads, alive_status, disposition
) VALUES (
@campaign_id, @name, @description, @notes, @motivations, @appearance, @age,
@gender, @pronouns, @voice, @distinguishing_features, @faction, @occupation,
@social_status, @relationship_to_pc, @loyalty, @personality_traits, @fears,
@desires, @secrets, @first_encountered, @last_seen, @current_location,
@current_goal, @role_in_threads, @alive_status, @disposition
)
`);
const updateNpcStmt = db.prepare(`
UPDATE npcs SET
name = @name,
description = @description,
notes = @notes,
motivations = @motivations,
appearance = @appearance,
age = @age,
gender = @gender,
pronouns = @pronouns,
voice = @voice,
distinguishing_features = @distinguishing_features,
faction = @faction,
occupation = @occupation,
social_status = @social_status,
relationship_to_pc = @relationship_to_pc,
loyalty = @loyalty,
personality_traits = @personality_traits,
fears = @fears,
desires = @desires,
secrets = @secrets,
first_encountered = @first_encountered,
last_seen = @last_seen,
current_location = @current_location,
current_goal = @current_goal,
role_in_threads = @role_in_threads,
alive_status = @alive_status,
disposition = @disposition
WHERE id = @id AND campaign_id = @campaign_id
`);
const deleteNpcStmt = db.prepare('DELETE FROM npcs WHERE id = ? AND campaign_id = ?');
function parsePositiveInt(raw, res, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
res.status(400).json({ error: `${label} must be a positive integer` });
return null;
}
return value;
}
function loadCampaignId(req, res) {
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
if (campaignId === null) return null;
if (!getCampaignByIdStmt.get(campaignId)) {
res.status(404).json({ error: 'campaign not found' });
return null;
}
return campaignId;
}
// Shared by create and update — PUT sends the full form, so both treat
// name/alive_status the same way rather than supporting partial patches.
function parseNpcPayload(body, res) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) {
res.status(400).json({ error: 'name is required' });
return null;
}
const aliveStatus = body.alive_status === undefined ? 'alive' : body.alive_status;
if (!ALIVE_STATUSES.includes(aliveStatus)) {
res.status(400).json({ error: `alive_status must be one of: ${ALIVE_STATUSES.join(', ')}` });
return null;
}
const payload = { name, alive_status: aliveStatus };
for (const field of OPTIONAL_TEXT_FIELDS) {
if (field === 'disposition') continue;
const value = body[field];
if (value === undefined || value === null) {
payload[field] = null;
} else if (typeof value === 'string') {
payload[field] = value;
} else {
res.status(400).json({ error: `${field} must be a string or null` });
return null;
}
}
const disposition = body.disposition;
if (disposition === undefined) {
payload.disposition = 'unknown';
} else if (disposition === null || typeof disposition === 'string') {
payload.disposition = disposition;
} else {
res.status(400).json({ error: 'disposition must be a string or null' });
return null;
}
return payload;
}
function withErrorHandling(handler) {
return (req, res) => {
try {
handler(req, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'unexpected server error' });
}
};
}
router.get('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
res.json({ data: listNpcsStmt.all(campaignId) });
}));
router.get('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
const npc = getNpcStmt.get(id, campaignId);
if (!npc) {
return res.status(404).json({ error: 'npc not found' });
}
res.json({ data: npc });
}));
router.post('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
const result = insertNpcStmt.run({ ...payload, campaign_id: campaignId });
res.status(201).json({ data: getNpcStmt.get(result.lastInsertRowid, campaignId) });
}));
router.put('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
updateNpcStmt.run({ ...payload, id, campaign_id: campaignId });
res.json({ data: getNpcStmt.get(id, campaignId) });
}));
router.delete('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
deleteNpcStmt.run(id, campaignId);
res.json({ data: { id } });
}));
module.exports = router;