From 85adbbf0846f888e679cdda8a8539c2063bd8c55 Mon Sep 17 00:00:00 2001 From: claudecode Date: Wed, 1 Jul 2026 18:13:38 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206=20=E2=80=94=20NPC=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 9 + public/css/styles.css | 317 ++++++++++++++++++++++++++++ public/index.html | 1 + public/js/api.js | 26 +++ public/js/npcs.js | 472 +++++++++++++++++++++++++++++++++++++++++- server/db.js | 36 +++- server/index.js | 2 +- server/routes/npcs.js | 225 +++++++++++++++++++- 8 files changed, 1078 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 454068a..fb8b0c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. --- diff --git a/public/css/styles.css b/public/css/styles.css index 687ad8b..a7912b4 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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)); +} diff --git a/public/index.html b/public/index.html index 0a6100e..4a0eea9 100644 --- a/public/index.html +++ b/public/index.html @@ -307,4 +307,5 @@ + diff --git a/public/js/api.js b/public/js/api.js index 268b6e6..4b695ac 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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; +} diff --git a/public/js/npcs.js b/public/js/npcs.js index 955bc6b..abe1907 100644 --- a/public/js/npcs.js +++ b/public/js/npcs.js @@ -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 = ` +
+

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 = '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); diff --git a/server/db.js b/server/db.js index 241f381..2da37db 100644 --- a/server/db.js +++ b/server/db.js @@ -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 ( diff --git a/server/index.js b/server/index.js index 4c8915a..c4f9157 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/routes/npcs.js b/server/routes/npcs.js index f577a6c..db9a1a8 100644 --- a/server/routes/npcs.js +++ b/server/routes/npcs.js @@ -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;