Phase 6 — NPC tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+29
-7
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user