diff --git a/server/index.js b/server/index.js index 8a2a92f..43d9e2b 100644 --- a/server/index.js +++ b/server/index.js @@ -1,25 +1,20 @@ // OTIVM server — OTIVM-IV // Per-player SQLite integration. -// TESSERA world database (data/otivm.sqlite3) — read-only, unchanged. -// Player databases (data/saves/{token}.sqlite3) — one per player, -// write-safe (single writer), created from data/create_player_db.sql -// on first access. -// JSON save files (data/saves/{token}.json) — never deleted, migrated -// transparently on first SQLite access if present. -// Frontend interface unchanged — GET/POST /api/save/:token same as before. // -// Change 1 (OTIVM-IV): on dispatch_start event, create ventures + -// venture_legs rows. On dispatch_complete, close venture with outcome_net. -// Change 2 (OTIVM-IV): on den change, write parameter_drift_log row -// (trigger_type = 'venture_complete'). On aut change, write -// parameter_drift_log row (trigger_type = 'interval_complete'). +// Fix 1: if actor_profile exists but background_id = 'unknown' and the +// incoming body has a real background_id, patch the profile and seed +// actor_parameters from background_starting_values. This handles the +// race where the first POST (background selection save) arrives before +// the background_id is set in the body. +// +// Fix 2: drift log entries are suppressed when there is no prior +// parameter row (prevDen = null / first write). This eliminates noise +// rows from the initial seeding sequence. // // Cost split — placeholder pending a proper cost model: // cost_vectura = 60% of route cost (VECTVRA — freight charge) // cost_portoria = 25% of route cost (PORTORIUM — customs duty) // cost_other = 15% of route cost (horreum, incidentals) -// These proportions are fixed constants here, not in the schema. -// When a real cost model is designed, this is the only place to change. import Fastify from 'fastify' import fastifyStatic from '@fastify/static' @@ -40,31 +35,23 @@ const SCHEMA_PATH = join(ROOT, 'data', 'create_player_db.sql') await mkdir(SAVES_DIR, { recursive: true }) // ── Cost split constants ───────────────────────────────────────────────────── -// Placeholder proportions — change here only when a real cost model is defined. const COST_VECTURA_RATIO = 0.60 const COST_PORTORIA_RATIO = 0.25 -// cost_other = remainder (1 - 0.60 - 0.25 = 0.15) -// Route mode classification — used for venture_legs.mode -// 'olive' and 'wine' are road; 'grain', 'linen' are sea. -// Extend this map when new routes are added. const ROUTE_MODE = { - olive: 'road', - wine: 'road', - grain: 'sea', - linen: 'sea', + olive: 'road', + wine: 'road', + grain: 'sea', + linen: 'sea', } -// Route cargo definitions — used for ventures.cargo_type / cargo_unit const ROUTE_CARGO = { - olive: { type: 'Olive oil, Garum', unit: 'amphora' }, - wine: { type: 'Campanian wine, Wool', unit: 'amphora' }, - grain: { type: 'Adriatic grain, Amber', unit: 'modius' }, - linen: { type: 'Berber linen, Frankincense',unit: 'talent' }, + olive: { type: 'Olive oil, Garum', unit: 'amphora' }, + wine: { type: 'Campanian wine, Wool', unit: 'amphora' }, + grain: { type: 'Adriatic grain, Amber', unit: 'modius' }, + linen: { type: 'Berber linen, Frankincense', unit: 'talent' }, } -// Route H3 waypoints — origin and destination res-5 cell IDs -// Sourced from src/constants.js WAYPOINTS and ROUTES const ROUTE_H3 = { olive: { origin: '851e805bfffffff', destination: '851e8333fffffff' }, wine: { origin: '851e8333fffffff', destination: '851e8ba3fffffff' }, @@ -72,8 +59,6 @@ const ROUTE_H3 = { linen: { origin: '85386e23fffffff', destination: '853f5ba7fffffff' }, } -// Route cost and profit — sourced from src/constants.js ROUTES -// Duplicated here so server/index.js has no dependency on frontend constants. const ROUTE_ECONOMICS = { olive: { cost: 8, profit: 12, duration_ms: 6000 }, wine: { cost: 14, profit: 22, duration_ms: 9000 }, @@ -81,7 +66,6 @@ const ROUTE_ECONOMICS = { linen: { cost: 38, profit: 70, duration_ms: 18000 }, } -// MS_PER_SIM_DAY — must match src/constants.js const MS_PER_SIM_DAY = 3_000 // ── TESSERA world database (read-only) ────────────────────────────────────── @@ -95,10 +79,10 @@ const stmtEpoch = db.prepare( const stmtH7 = db.prepare(` SELECT h7, - AVG(lat) AS lat, - AVG(lon) AS lon, - SUM(CASE WHEN elev_cm > ? THEN 1 ELSE 0 END) AS h7_land, - COUNT(*) AS h9_total + AVG(lat) AS lat, + AVG(lon) AS lon, + SUM(CASE WHEN elev_cm > ? THEN 1 ELSE 0 END) AS h7_land, + COUNT(*) AS h9_total FROM tessera_cells WHERE h5 = ? AND status = 2 GROUP BY h7 @@ -124,8 +108,6 @@ function openPlayerDb(token) { return pdb } -// ── UUID generator ─────────────────────────────────────────────────────────── -// Simple RFC4122 v4 UUID — no external dependency needed. function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0 @@ -137,18 +119,16 @@ function uuid() { function migrateJsonToSqlite(token, pdb, json) { const now = new Date().toISOString() - const actorId = token + const actorId = token const sessionId = token - const background = json.background_id || 'unknown' const name = json.actor_name || 'Mercator' - const epoch = 'roman_14bce' pdb.prepare(` INSERT OR IGNORE INTO actor_profile (actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at) - VALUES (?, ?, ?, ?, ?, 5, ?) - `).run(actorId, sessionId, background, name, epoch, now) + VALUES (?, ?, ?, ?, 'roman_14bce', 5, ?) + `).run(actorId, sessionId, background, name, now) pdb.prepare(` INSERT OR IGNORE INTO actor_parameters @@ -173,8 +153,7 @@ function migrateJsonToSqlite(token, pdb, json) { (actor_id, parameter_token, scope, layer, value_true, value_perceived, confidence_tag, observable_level, drift_source, recorded_at) - VALUES (?, 'liquiditas', 'actor', 'roman', - ?, ?, 'measured', 'full', 'migration', ?) + VALUES (?, 'liquiditas', 'actor', 'roman', ?, ?, 'measured', 'full', 'migration', ?) `).run(actorId, String(json.den), String(json.den), now) } @@ -185,8 +164,7 @@ function migrateJsonToSqlite(token, pdb, json) { (actor_id, parameter_token, scope, layer, value_true, value_perceived, value_social, confidence_tag, observable_level, drift_source, recorded_at) - VALUES (?, 'auctoritas', 'actor', 'roman', - ?, ?, ?, 'indicated', 'partial', 'migration', ?) + VALUES (?, 'auctoritas', 'actor', 'roman', ?, ?, ?, 'indicated', 'partial', 'migration', ?) `).run(actorId, band, band, band, now) } @@ -196,15 +174,13 @@ function migrateJsonToSqlite(token, pdb, json) { `) for (const ev of (json.events || [])) { insertEvent.run( - actorId, - ev.type || 'unknown', + actorId, ev.type || 'unknown', ev.route_id || null, ev.route_id ? 'venture' : null, JSON.stringify(ev), ev.timestamp_utc || now ) } - insertEvent.run( actorId, 'session_start', null, null, JSON.stringify({ source: 'json_migration', schema_version: 5 }), @@ -232,12 +208,12 @@ function readPlayerState(token, pdb) { const paramMap = {} for (const p of params) { - paramMap[p.parameter_token] = p + if (!paramMap[p.parameter_token]) paramMap[p.parameter_token] = p } - const den = parseInt(paramMap['liquiditas']?.value_true || '0', 10) + const den = parseInt(paramMap['liquiditas']?.value_true || '0', 10) const autStr = paramMap['auctoritas']?.value_true || 'low' - const aut = autToInt(autStr) + const aut = autToInt(autStr) const events = pdb.prepare(` SELECT event_type AS type, ref_id AS route_id, payload, recorded_at AS timestamp_utc @@ -271,7 +247,7 @@ function readPlayerState(token, pdb) { started_utc: ev.timestamp_utc, duration_ms: p.duration_ms || 0, } - } catch { /* ignore malformed payload */ } + } catch { /* ignore */ } } if (ev.type === 'venture_complete' || ev.type === 'dispatch_complete') { active_dispatch = null @@ -289,8 +265,8 @@ function readPlayerState(token, pdb) { return { token, - background_id: profile?.background_id || 'unknown', - actor_name: profile?.actor_name || 'Mercator', + background_id: profile?.background_id || 'unknown', + actor_name: profile?.actor_name || 'Mercator', den, aut, chapter, @@ -303,7 +279,7 @@ function readPlayerState(token, pdb) { route_id: e.route_id, timestamp_utc: e.timestamp_utc, })), - created_at: profile?.recorded_at || new Date().toISOString(), + created_at: profile?.recorded_at || new Date().toISOString(), schema_version: 5, } } @@ -317,9 +293,49 @@ function autToInt(band) { } } +// ── Seed actor_parameters from background_starting_values ──────────────────── +// Extracted as a standalone function so it can be called both at new-player +// creation and at the background-patch step (Fix 1). + +function seedParameters(pdb, actorId, backgroundId, denOverride, now) { + pdb.prepare(` + INSERT OR IGNORE INTO actor_parameters + (actor_id, parameter_token, scope, layer, + value_true, value_perceived, value_social, + confidence_tag, observable_level, drift_source, recorded_at) + SELECT + ?, parameter_token, 'actor', + CASE WHEN parameter_token IN ('auctoritas','clientela','liquiditas','fama', + 'disciplina','mercatus_scientia','itineris_scientia','ius_accessus', + 'periculum_tolerantia','negotiatio','litterae','officia_burden') + THEN 'roman' ELSE 'universal' END, + value_true, value_perceived, + CASE WHEN parameter_token = 'auctoritas' THEN value_true ELSE NULL END, + confidence_tag, observable_level, 'initial', ? + FROM background_starting_values + WHERE background_id = ? + `).run(actorId, now, backgroundId) + + // Override liquiditas with the confirmed starting den from the frontend. + // Supersede the seed row immediately so the true starting value is authoritative. + if (denOverride !== undefined) { + pdb.prepare(` + UPDATE actor_parameters + SET superseded_at = ? + WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL + `).run(now, actorId) + pdb.prepare(` + INSERT INTO actor_parameters + (actor_id, parameter_token, scope, layer, + value_true, value_perceived, + confidence_tag, observable_level, drift_source, recorded_at) + VALUES (?, 'liquiditas', 'actor', 'roman', ?, ?, 'measured', 'full', 'initial', ?) + `).run(actorId, String(denOverride), String(denOverride), now) + } +} + // ── Venture helpers ────────────────────────────────────────────────────────── -// Find the open (active) venture for this actor, if any. function findActiveVenture(pdb, actorId) { return pdb.prepare(` SELECT venture_id, venture_label FROM ventures @@ -328,15 +344,12 @@ function findActiveVenture(pdb, actorId) { `).get(actorId) } -// Create a venture + venture_leg row on dispatch_start. -// Returns the new venture_id. function createVenture(pdb, actorId, routeId, now) { const eco = ROUTE_ECONOMICS[routeId] const cargo = ROUTE_CARGO[routeId] const h3 = ROUTE_H3[routeId] const mode = ROUTE_MODE[routeId] || 'road' - - if (!eco || !cargo || !h3) return null // unknown route — skip silently + if (!eco || !cargo || !h3) return null const ventureId = uuid() const legId = uuid() @@ -344,7 +357,7 @@ function createVenture(pdb, actorId, routeId, now) { const costVectura = Math.round(eco.cost * COST_VECTURA_RATIO * 100) / 100 const costPortoria = Math.round(eco.cost * COST_PORTORIA_RATIO * 100) / 100 const costOther = Math.round((eco.cost - costVectura - costPortoria) * 100) / 100 - const durationDays = Math.round(eco.duration_ms / MS_PER_SIM_DAY) // INTEGER + const durationDays = Math.round(eco.duration_ms / MS_PER_SIM_DAY) const label = `${routeId.charAt(0).toUpperCase() + routeId.slice(1)} route` @@ -354,8 +367,7 @@ function createVenture(pdb, actorId, routeId, now) { cargo_type, cargo_unit, cost_total, recorded_at, started_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?) - `).run(ventureId, actorId, label, cargo.type, cargo.unit, - eco.cost, now, now) + `).run(ventureId, actorId, label, cargo.type, cargo.unit, eco.cost, now, now) pdb.prepare(` INSERT INTO venture_legs @@ -365,26 +377,20 @@ function createVenture(pdb, actorId, routeId, now) { status, recorded_at, started_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) `).run(legId, ventureId, h3.origin, h3.destination, mode, - durationDays, costVectura, costPortoria, costOther, eco.cost, - now, now) + durationDays, costVectura, costPortoria, costOther, eco.cost, now, now) return ventureId } -// Close the active venture on dispatch_complete. -// Writes outcome_net, completed_at, and closes the leg. function closeVenture(pdb, actorId, routeId, ventureId, now) { const eco = ROUTE_ECONOMICS[routeId] if (!eco) return - const outcomeNet = eco.profit - eco.cost - pdb.prepare(` UPDATE ventures SET status = 'complete', revenue_total = ?, outcome_net = ?, completed_at = ? WHERE venture_id = ? `).run(eco.profit, outcomeNet, now, ventureId) - pdb.prepare(` UPDATE venture_legs SET status = 'complete', completed_at = ? @@ -392,7 +398,7 @@ function closeVenture(pdb, actorId, routeId, ventureId, now) { `).run(now, ventureId) } -// ── Drift log helpers ──────────────────────────────────────────────────────── +// ── Drift log ──────────────────────────────────────────────────────────────── function writeDriftLog(pdb, actorId, paramToken, triggerType, triggerRef, valueBefore, valueAfter, deltaNote, now) { @@ -408,16 +414,22 @@ function writeDriftLog(pdb, actorId, paramToken, triggerType, triggerRef, // ── Write player state ─────────────────────────────────────────────────────── function writePlayerState(token, pdb, body) { - const now = new Date().toISOString() + const now = new Date().toISOString() const actorId = token - // Ensure actor_profile exists + const incomingBackground = body.background_id + const isRealBackground = incomingBackground && incomingBackground !== 'unknown' + + // Load existing profile (may or may not exist) const profile = pdb.prepare( - 'SELECT actor_id FROM actor_profile WHERE actor_id = ? LIMIT 1' + 'SELECT actor_id, background_id FROM actor_profile WHERE actor_id = ? LIMIT 1' ).get(actorId) if (!profile) { - const backgroundId = body.background_id || 'unknown' + // Brand new player — create profile regardless of background_id. + // If background is still 'unknown', parameters are not seeded yet; + // they will be seeded on the next POST that arrives with a real background. + const backgroundId = incomingBackground || 'unknown' const actorName = body.actor_name || 'Mercator' pdb.prepare(` @@ -426,42 +438,23 @@ function writePlayerState(token, pdb, body) { VALUES (?, ?, ?, ?, 'roman_14bce', 5, ?) `).run(actorId, actorId, backgroundId, actorName, now) - pdb.prepare(` - INSERT OR IGNORE INTO actor_parameters - (actor_id, parameter_token, scope, layer, - value_true, value_perceived, value_social, - confidence_tag, observable_level, drift_source, recorded_at) - SELECT - ?, parameter_token, 'actor', - CASE WHEN parameter_token IN ('auctoritas','clientela','liquiditas','fama', - 'disciplina','mercatus_scientia','itineris_scientia','ius_accessus', - 'periculum_tolerantia','negotiatio','litterae','officia_burden') - THEN 'roman' ELSE 'universal' END, - value_true, value_perceived, - CASE WHEN parameter_token = 'auctoritas' THEN value_true ELSE NULL END, - confidence_tag, observable_level, 'initial', ? - FROM background_starting_values - WHERE background_id = ? - `).run(actorId, now, backgroundId) - - if (body.den !== undefined) { - pdb.prepare(` - UPDATE actor_parameters - SET superseded_at = ? - WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL - `).run(now, actorId) - pdb.prepare(` - INSERT INTO actor_parameters - (actor_id, parameter_token, scope, layer, - value_true, value_perceived, - confidence_tag, observable_level, drift_source, recorded_at) - VALUES (?, 'liquiditas', 'actor', 'roman', - ?, ?, 'measured', 'full', 'initial', ?) - `).run(actorId, String(body.den), String(body.den), now) + if (isRealBackground) { + seedParameters(pdb, actorId, backgroundId, body.den, now) } + + } else if (profile.background_id === 'unknown' && isRealBackground) { + // Fix 1: profile exists but background was not set on first write. + // Patch the profile and seed parameters now. + pdb.prepare(` + UPDATE actor_profile SET background_id = ?, schema_version = 5 WHERE actor_id = ? + `).run(incomingBackground, actorId) + + seedParameters(pdb, actorId, incomingBackground, body.den, now) + + console.log(`[profile] Patched background_id for ${token}: unknown → ${incomingBackground}`) } - // Read current den/aut before any update — needed for drift log + // Read current parameter values — needed for drift log (Fix 2: null check) const currentLiq = pdb.prepare(` SELECT value_true FROM actor_parameters WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL @@ -473,14 +466,14 @@ function writePlayerState(token, pdb, body) { ORDER BY recorded_at DESC LIMIT 1 `).get(actorId) - const prevDen = currentLiq?.value_true ?? '0' - const prevAut = currentAut?.value_true ?? 'low' + // Fix 2: only record drift when a prior row exists (not during initial seeding) + const prevDen = currentLiq?.value_true ?? null + const prevAut = currentAut?.value_true ?? null - // ── Process new events (Change 1 + Change 2) ───────────────────────────── + // ── Process new events ─────────────────────────────────────────────────── const lastRecorded = pdb.prepare(` - SELECT recorded_at FROM events - WHERE actor_id = ? + SELECT recorded_at FROM events WHERE actor_id = ? ORDER BY recorded_at DESC LIMIT 1 `).get(actorId) const lastTs = lastRecorded?.recorded_at || '1970-01-01T00:00:00.000Z' @@ -496,26 +489,22 @@ function writePlayerState(token, pdb, body) { const routeId = ev.route_id || null - // Change 1 — venture creation on dispatch_start if (ev.type === 'dispatch_start' && routeId) { const ventureId = createVenture(pdb, actorId, routeId, ts) insertEvent.run( actorId, ev.type, routeId, 'venture', - JSON.stringify({ ...ev, venture_id: ventureId }), - ts + JSON.stringify({ ...ev, venture_id: ventureId }), ts ) continue } - // Change 1 — venture closure on dispatch_complete if (ev.type === 'dispatch_complete' && routeId) { const active = findActiveVenture(pdb, actorId) if (active) { closeVenture(pdb, actorId, routeId, active.venture_id, ts) insertEvent.run( actorId, ev.type, routeId, 'venture', - JSON.stringify({ ...ev, venture_id: active.venture_id }), - ts + JSON.stringify({ ...ev, venture_id: active.venture_id }), ts ) } else { insertEvent.run( @@ -526,23 +515,19 @@ function writePlayerState(token, pdb, body) { continue } - // All other events — write as-is insertEvent.run( - actorId, - ev.type || 'unknown', + actorId, ev.type || 'unknown', routeId, routeId ? 'venture' : null, - JSON.stringify(ev), - ts + JSON.stringify(ev), ts ) } - // ── Update parameters + drift log (Change 2) ───────────────────────────── + // ── Update parameters + drift log ──────────────────────────────────────── if (body.den !== undefined) { const newDen = String(body.den) - if (newDen !== prevDen) { - // Find the venture that just completed, if any — use as trigger_ref + if (prevDen !== null && newDen !== prevDen) { const lastVenture = pdb.prepare(` SELECT venture_id, venture_label FROM ventures WHERE actor_id = ? AND status = 'complete' @@ -559,14 +544,16 @@ function writePlayerState(token, pdb, body) { lastVenture?.venture_label || null, now ) + } else if (prevDen === null) { + // No prior row — update silently, no drift log entry + updateParam(pdb, actorId, 'liquiditas', newDen, newDen, now) } } if (body.aut !== undefined) { const newBand = autBand(body.aut) - if (newBand !== prevAut) { + if (prevAut !== null && newBand !== prevAut) { updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now) - writeDriftLog( pdb, actorId, 'auctoritas', 'interval_complete', @@ -575,17 +562,17 @@ function writePlayerState(token, pdb, body) { 'otium rest', now ) + } else if (prevAut === null) { + updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now) } } } function updateParam(pdb, actorId, token, valueTrue, valuePerceived, now) { pdb.prepare(` - UPDATE actor_parameters - SET superseded_at = ? + UPDATE actor_parameters SET superseded_at = ? WHERE actor_id = ? AND parameter_token = ? AND superseded_at IS NULL `).run(now, actorId, token) - pdb.prepare(` INSERT INTO actor_parameters (actor_id, parameter_token, scope, layer, @@ -597,11 +584,9 @@ function updateParam(pdb, actorId, token, valueTrue, valuePerceived, now) { function updateParamWithSocial(pdb, actorId, token, valueTrue, valuePerceived, valueSocial, now) { pdb.prepare(` - UPDATE actor_parameters - SET superseded_at = ? + UPDATE actor_parameters SET superseded_at = ? WHERE actor_id = ? AND parameter_token = ? AND superseded_at IS NULL `).run(now, actorId, token) - pdb.prepare(` INSERT INTO actor_parameters (actor_id, parameter_token, scope, layer, @@ -615,61 +600,45 @@ function updateParamWithSocial(pdb, actorId, token, valueTrue, valuePerceived, v const fastify = Fastify({ logger: false }) -await fastify.register(fastifyStatic, { - root: DIST, - prefix: '/', -}) +await fastify.register(fastifyStatic, { root: DIST, prefix: '/' }) fastify.get('/api/map/:h5/:epoch', async (req, reply) => { const { h5: h5param, epoch: epochKey } = req.params - if (!/^[0-9a-f]{15}$/.test(h5param)) { + if (!/^[0-9a-f]{15}$/.test(h5param)) return reply.code(400).send({ error: 'Invalid H5 ID' }) - } - if (!/^[a-z0-9_]+$/.test(epochKey)) { + if (!/^[a-z0-9_]+$/.test(epochKey)) return reply.code(400).send({ error: 'Invalid epoch key' }) - } const epoch = stmtEpoch.get(epochKey) - if (!epoch) { - return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` }) - } + if (!epoch) return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` }) let h5int - try { - h5int = h3HexToInt(h5param) - } catch { - return reply.code(400).send({ error: 'Malformed H5 ID' }) - } + try { h5int = h3HexToInt(h5param) } + catch { return reply.code(400).send({ error: 'Malformed H5 ID' }) } const rows = stmtH7.all(epoch.sl_offset_cm, h5int) - - if (!rows.length) { - return reply.code(404).send({ error: `No data for H5: ${h5param}` }) - } - - const cells = rows.map(row => ({ - h7: row.h7.toString(16), - lat: row.lat, - lon: row.lon, - h7_land: row.h7_land, - h9_total: row.h9_total, - is_land: row.h7_land * 2 > row.h9_total ? 1 : 0, - })) + if (!rows.length) return reply.code(404).send({ error: `No data for H5: ${h5param}` }) return reply.send({ epoch_key: epochKey, sl_offset_cm: epoch.sl_offset_cm, h5: h5param, - cells, + cells: rows.map(row => ({ + h7: row.h7.toString(16), + lat: row.lat, + lon: row.lon, + h7_land: row.h7_land, + h9_total: row.h9_total, + is_land: row.h7_land * 2 > row.h9_total ? 1 : 0, + })), }) }) fastify.get('/api/save/:token', async (req, reply) => { const { token } = req.params - if (!/^[0-9a-f]{8}$/.test(token)) { + if (!/^[0-9a-f]{8}$/.test(token)) return reply.code(400).send({ error: 'Invalid token' }) - } const sqlitePath = join(SAVES_DIR, `${token}.sqlite3`) const jsonPath = join(SAVES_DIR, `${token}.json`) @@ -707,12 +676,10 @@ fastify.get('/api/save/:token', async (req, reply) => { fastify.post('/api/save/:token', async (req, reply) => { const { token } = req.params - if (!/^[0-9a-f]{8}$/.test(token)) { + if (!/^[0-9a-f]{8}$/.test(token)) return reply.code(400).send({ error: 'Invalid token' }) - } - if (!req.body || typeof req.body !== 'object') { + if (!req.body || typeof req.body !== 'object') return reply.code(400).send({ error: 'Invalid body' }) - } try { const pdb = openPlayerDb(token) @@ -725,13 +692,11 @@ fastify.post('/api/save/:token', async (req, reply) => { } }) -fastify.setNotFoundHandler((req, reply) => { - reply.sendFile('index.html') -}) +fastify.setNotFoundHandler((req, reply) => { reply.sendFile('index.html') }) try { await fastify.listen({ port: 3000, host: '0.0.0.0' }) - console.log('OTIVM server running on port 3000 — OTIVM-IV (Changes 1+2)') + console.log('OTIVM server running on port 3000 — OTIVM-IV (fix: background_id patch)') } catch (err) { console.error(err) process.exit(1)