Files
claudecode 85adbbf084 Phase 6 — NPC tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:13:38 -04:00

228 lines
6.6 KiB
JavaScript

const express = require('express');
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;