From 30fe79e9ed12912ebdfa03a084a18d2d8e314d0b Mon Sep 17 00:00:00 2001 From: otivm Date: Sun, 3 May 2026 08:40:32 +0000 Subject: [PATCH] iv: wire ventures, venture_legs, and parameter_drift_log (Changes 1+2) --- server/index.js | 400 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 275 insertions(+), 125 deletions(-) diff --git a/server/index.js b/server/index.js index 2543ef5..8a2a92f 100644 --- a/server/index.js +++ b/server/index.js @@ -8,29 +8,82 @@ // transparently on first SQLite access if present. // Frontend interface unchanged — GET/POST /api/save/:token same as before. // -// Change 3 (OTIVM-IV): background_id from the frontend is now a real -// canonical identifier (e.g. 'former_legionary'). On new player creation, -// seed actor_parameters from background_starting_values for that background. -// The previous default of 'unknown' is kept only as a fallback for legacy -// saves that predate Change 3. +// 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'). +// +// 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' -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, mkdir } from 'fs/promises' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { existsSync } from 'fs' +import { readFileSync } from 'fs' import Database from 'better-sqlite3' const __dirname = dirname(fileURLToPath(import.meta.url)) -const ROOT = join(__dirname, '..') -const DIST = join(ROOT, 'dist') -const SAVES_DIR = join(ROOT, 'data', 'saves') -const DB_PATH = join(ROOT, 'data', 'otivm.sqlite3') +const ROOT = join(__dirname, '..') +const DIST = join(ROOT, 'dist') +const SAVES_DIR = join(ROOT, 'data', 'saves') +const DB_PATH = join(ROOT, 'data', 'otivm.sqlite3') 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', +} + +// 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' }, +} + +// 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' }, + grain: { origin: '851e8ba3fffffff', destination: '85386e23fffffff' }, + 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 }, + grain: { cost: 24, profit: 40, duration_ms: 12000 }, + 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) ────────────────────────────────────── const db = new Database(DB_PATH, { readonly: true }) @@ -56,19 +109,9 @@ function h3HexToInt(hexStr) { } // ── Per-player database ────────────────────────────────────────────────────── -// -// One SQLite file per player at data/saves/{token}.sqlite3. -// Created from data/create_player_db.sql on first access. -// Write-safe: only one process writes to a given file at a time. -// The better-sqlite3 instance is opened, used, and closed per request — -// no persistent connection pool needed at 128 concurrent players. -// Read the schema once at startup — better-sqlite3 exec() requires the full SQL string. -import { readFileSync } from 'fs' const PLAYER_SCHEMA_SQL = readFileSync(SCHEMA_PATH, 'utf8') -// Open (or create) the player database for a given token. -// Returns a better-sqlite3 Database instance — caller must close it. function openPlayerDb(token) { const path = join(SAVES_DIR, `${token}.sqlite3`) const isNew = !existsSync(path) @@ -76,38 +119,38 @@ function openPlayerDb(token) { pdb.pragma('journal_mode = WAL') pdb.pragma('foreign_keys = ON') if (isNew) { - // Run the full schema — idempotent due to IF NOT EXISTS clauses. pdb.exec(PLAYER_SCHEMA_SQL) } 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 + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) + }) +} + // ── JSON → SQLite migration ────────────────────────────────────────────────── -// -// Called once per token when a .json exists but no .sqlite3 exists. -// Imports the flat JSON game state into the events and actor_parameters -// tables so the behavioral record is preserved. -// The JSON file is left in place — never deleted. function migrateJsonToSqlite(token, pdb, json) { const now = new Date().toISOString() const actorId = token const sessionId = token - // actor_profile — one row anchoring the actor const background = json.background_id || 'unknown' const name = json.actor_name || 'Mercator' const epoch = 'roman_14bce' - const insertProfile = pdb.prepare(` + pdb.prepare(` INSERT OR IGNORE INTO actor_profile (actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at) - VALUES (?, ?, ?, ?, ?, 3, ?) - `) - insertProfile.run(actorId, sessionId, background, name, epoch, now) + VALUES (?, ?, ?, ?, ?, 5, ?) + `).run(actorId, sessionId, background, name, epoch, now) - // Seed actor_parameters from background_starting_values if not already seeded - const seedParams = pdb.prepare(` + pdb.prepare(` INSERT OR IGNORE INTO actor_parameters (actor_id, parameter_token, scope, layer, value_true, value_perceived, value_social, @@ -122,43 +165,36 @@ function migrateJsonToSqlite(token, pdb, json) { confidence_tag, observable_level, 'migration', ? FROM background_starting_values WHERE background_id = ? - `) - seedParams.run(actorId, now, background) + `).run(actorId, now, background) - // Override liquiditas with actual value from JSON if available if (json.den !== undefined) { - const upsertLiq = pdb.prepare(` + 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', 'migration', ?) - `) - upsertLiq.run(actorId, String(json.den), String(json.den), now) + `).run(actorId, String(json.den), String(json.den), now) } - // Override auctoritas with actual value from JSON if available if (json.aut !== undefined) { - const upsertAut = pdb.prepare(` + const band = autBand(json.aut) + pdb.prepare(` INSERT INTO actor_parameters (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', ?) - `) - const band = autBand(json.aut) - upsertAut.run(actorId, band, band, band, now) + `).run(actorId, band, band, band, now) } - // Import legacy events[] array into the events table const insertEvent = pdb.prepare(` INSERT INTO events (actor_id, event_type, ref_id, ref_type, payload, recorded_at) VALUES (?, ?, ?, ?, ?, ?) `) - const eventsArr = json.events || [] - for (const ev of eventsArr) { + for (const ev of (json.events || [])) { insertEvent.run( actorId, ev.type || 'unknown', @@ -169,18 +205,13 @@ function migrateJsonToSqlite(token, pdb, json) { ) } - // Record the migration itself as an event insertEvent.run( - actorId, - 'session_start', - null, - null, - JSON.stringify({ source: 'json_migration', schema_version: 3 }), + actorId, 'session_start', null, null, + JSON.stringify({ source: 'json_migration', schema_version: 5 }), now ) } -// Convert raw auctoritas integer (OTIVM-I/II) to ordinal band. function autBand(aut) { if (aut >= 30) return 'distinguished' if (aut >= 15) return 'high' @@ -188,14 +219,9 @@ function autBand(aut) { return 'low' } -// ── Read player state from SQLite ──────────────────────────────────────────── -// -// Returns the current game state as the frontend expects it. -// Derives the flat JSON shape from the relational records so the -// frontend requires zero changes. +// ── Read player state ──────────────────────────────────────────────────────── function readPlayerState(token, pdb) { - // Current parameter values — most recent non-superseded row per token const params = pdb.prepare(` SELECT parameter_token, value_true, value_perceived, value_social, confidence_tag, observable_level @@ -213,7 +239,6 @@ function readPlayerState(token, pdb) { const autStr = paramMap['auctoritas']?.value_true || 'low' const aut = autToInt(autStr) - // Events — chronological const events = pdb.prepare(` SELECT event_type AS type, ref_id AS route_id, payload, recorded_at AS timestamp_utc FROM events @@ -221,7 +246,6 @@ function readPlayerState(token, pdb) { ORDER BY recorded_at ASC `).all(token) - // Derive dispatches and route_dispatches from event log let dispatches = 0 const route_dispatches = {} const journal_seen = [] @@ -254,13 +278,11 @@ function readPlayerState(token, pdb) { } } - // chapter derived from den + aut if (aut >= 30 && den >= 800) chapter = 5 else if (aut >= 15 && den >= 350) chapter = 4 else if (aut >= 5 && den >= 120) chapter = 3 else if (den >= 40) chapter = 2 - // actor_profile const profile = pdb.prepare( 'SELECT * FROM actor_profile WHERE actor_id = ? ORDER BY recorded_at DESC LIMIT 1' ).get(token) @@ -282,11 +304,10 @@ function readPlayerState(token, pdb) { timestamp_utc: e.timestamp_utc, })), created_at: profile?.recorded_at || new Date().toISOString(), - schema_version: 3, + schema_version: 5, } } -// Convert ordinal band back to a representative integer for chapter logic. function autToInt(band) { switch (band) { case 'distinguished': return 35 @@ -296,19 +317,95 @@ function autToInt(band) { } } -// ── Write player state to SQLite ───────────────────────────────────────────── -// -// Receives the flat JSON game state from the frontend. -// Writes parameter changes as new actor_parameters rows (append-only). -// Appends new events to the events table. -// Marks superseded parameter rows. -// -// On new player creation (no actor_profile row yet): -// - Uses background_id from the request body (sent by frontend after -// player chooses on the Prologue tab). -// - Seeds actor_parameters from background_starting_values for that background. -// - Sets liquiditas to the background's starting_den value from the body. -// - Falls back to 'unknown' only if no background_id is present (legacy saves). +// ── 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 + WHERE actor_id = ? AND status = 'active' + ORDER BY recorded_at DESC LIMIT 1 + `).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 + + const ventureId = uuid() + const legId = uuid() + + 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 label = `${routeId.charAt(0).toUpperCase() + routeId.slice(1)} route` + + pdb.prepare(` + INSERT INTO ventures + (venture_id, actor_id, venture_label, status, + cargo_type, cargo_unit, cost_total, + recorded_at, started_at) + VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?) + `).run(ventureId, actorId, label, cargo.type, cargo.unit, + eco.cost, now, now) + + pdb.prepare(` + INSERT INTO venture_legs + (leg_id, venture_id, leg_sequence, + origin_h3, destination_h3, mode, + duration_days, cost_vectura, cost_portoria, cost_other, cost_total, + status, recorded_at, started_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + `).run(legId, ventureId, h3.origin, h3.destination, mode, + 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 = ? + WHERE venture_id = ? AND status = 'active' + `).run(now, ventureId) +} + +// ── Drift log helpers ──────────────────────────────────────────────────────── + +function writeDriftLog(pdb, actorId, paramToken, triggerType, triggerRef, + valueBefore, valueAfter, deltaNote, now) { + pdb.prepare(` + INSERT INTO parameter_drift_log + (actor_id, parameter_token, trigger_type, trigger_ref, + value_before, value_after, delta_note, recorded_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(actorId, paramToken, triggerType, triggerRef, + String(valueBefore), String(valueAfter), deltaNote, now) +} + +// ── Write player state ─────────────────────────────────────────────────────── function writePlayerState(token, pdb, body) { const now = new Date().toISOString() @@ -320,19 +417,15 @@ function writePlayerState(token, pdb, body) { ).get(actorId) if (!profile) { - // New player — use the background_id sent by the frontend. - // The Prologue tab ensures this is a canonical identifier before sending. const backgroundId = body.background_id || 'unknown' const actorName = body.actor_name || 'Mercator' pdb.prepare(` INSERT OR IGNORE INTO actor_profile (actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at) - VALUES (?, ?, ?, ?, 'roman_14bce', 4, ?) + VALUES (?, ?, ?, ?, 'roman_14bce', 5, ?) `).run(actorId, actorId, backgroundId, actorName, now) - // Seed all parameters from background_starting_values for chosen background. - // auctoritas gets value_social seeded from the same row's value_true. pdb.prepare(` INSERT OR IGNORE INTO actor_parameters (actor_id, parameter_token, scope, layer, @@ -351,17 +444,12 @@ function writePlayerState(token, pdb, body) { WHERE background_id = ? `).run(actorId, now, backgroundId) - // Override liquiditas with the den value from the body (which was set - // from BACKGROUNDS[x].starting_den on the frontend). This is the - // authoritative starting value — the seed row is superseded immediately. if (body.den !== undefined) { - // Supersede the seed row pdb.prepare(` UPDATE actor_parameters SET superseded_at = ? WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL `).run(now, actorId) - // Insert the confirmed starting value pdb.prepare(` INSERT INTO actor_parameters (actor_id, parameter_token, scope, layer, @@ -373,18 +461,23 @@ function writePlayerState(token, pdb, body) { } } - // Update liquiditas if den changed - if (body.den !== undefined) { - updateParam(pdb, actorId, 'liquiditas', String(body.den), String(body.den), now) - } + // Read current den/aut before any update — needed for drift log + const currentLiq = pdb.prepare(` + SELECT value_true FROM actor_parameters + WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL + ORDER BY recorded_at DESC LIMIT 1 + `).get(actorId) + const currentAut = pdb.prepare(` + SELECT value_true FROM actor_parameters + WHERE actor_id = ? AND parameter_token = 'auctoritas' AND superseded_at IS NULL + ORDER BY recorded_at DESC LIMIT 1 + `).get(actorId) - // Update auctoritas if aut changed - if (body.aut !== undefined) { - const band = autBand(body.aut) - updateParamWithSocial(pdb, actorId, 'auctoritas', band, band, band, now) - } + const prevDen = currentLiq?.value_true ?? '0' + const prevAut = currentAut?.value_true ?? 'low' + + // ── Process new events (Change 1 + Change 2) ───────────────────────────── - // Append new events — only those not already recorded const lastRecorded = pdb.prepare(` SELECT recorded_at FROM events WHERE actor_id = ? @@ -399,20 +492,93 @@ function writePlayerState(token, pdb, body) { for (const ev of (body.events || [])) { const ts = ev.timestamp_utc || now - if (ts > lastTs) { + if (ts <= lastTs) continue + + 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 || 'unknown', - ev.route_id || null, - ev.route_id ? 'venture' : null, - JSON.stringify(ev), + actorId, ev.type, routeId, 'venture', + 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 + ) + } else { + insertEvent.run( + actorId, ev.type, routeId, 'venture', + JSON.stringify(ev), ts + ) + } + continue + } + + // All other events — write as-is + insertEvent.run( + actorId, + ev.type || 'unknown', + routeId, + routeId ? 'venture' : null, + JSON.stringify(ev), + ts + ) + } + + // ── Update parameters + drift log (Change 2) ───────────────────────────── + + if (body.den !== undefined) { + const newDen = String(body.den) + if (newDen !== prevDen) { + // Find the venture that just completed, if any — use as trigger_ref + const lastVenture = pdb.prepare(` + SELECT venture_id, venture_label FROM ventures + WHERE actor_id = ? AND status = 'complete' + ORDER BY completed_at DESC LIMIT 1 + `).get(actorId) + + updateParam(pdb, actorId, 'liquiditas', newDen, newDen, now) + + writeDriftLog( + pdb, actorId, 'liquiditas', + 'venture_complete', + lastVenture?.venture_id || null, + prevDen, newDen, + lastVenture?.venture_label || null, + now + ) + } + } + + if (body.aut !== undefined) { + const newBand = autBand(body.aut) + if (newBand !== prevAut) { + updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now) + + writeDriftLog( + pdb, actorId, 'auctoritas', + 'interval_complete', + null, + prevAut, newBand, + 'otium rest', + now + ) } } } -// Append a new actor_parameters row and supersede the previous one. function updateParam(pdb, actorId, token, valueTrue, valuePerceived, now) { pdb.prepare(` UPDATE actor_parameters @@ -454,11 +620,6 @@ await fastify.register(fastifyStatic, { prefix: '/', }) -// GET /api/map/:h5/:epoch -// Returns H7-aggregated land/sea classification with real centroids. -// h5param: H3 res-5 hex string (e.g. '851e805bfffffff') -// epochKey: named epoch from paleo_epochs (e.g. 'roman_14bce') -// Response: { epoch_key, sl_offset_cm, h5, cells: [{ h7, lat, lon, h7_land, h9_total, is_land }] } fastify.get('/api/map/:h5/:epoch', async (req, reply) => { const { h5: h5param, epoch: epochKey } = req.params @@ -474,8 +635,6 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => { return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` }) } - const slOffsetCm = epoch.sl_offset_cm - let h5int try { h5int = h3HexToInt(h5param) @@ -483,7 +642,7 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => { return reply.code(400).send({ error: 'Malformed H5 ID' }) } - const rows = stmtH7.all(slOffsetCm, h5int) + const rows = stmtH7.all(epoch.sl_offset_cm, h5int) if (!rows.length) { return reply.code(404).send({ error: `No data for H5: ${h5param}` }) @@ -500,15 +659,12 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => { return reply.send({ epoch_key: epochKey, - sl_offset_cm: slOffsetCm, + sl_offset_cm: epoch.sl_offset_cm, h5: h5param, cells, }) }) -// GET /api/save/:token -// Returns the current player state. -// Priority: SQLite > JSON migration > 404. fastify.get('/api/save/:token', async (req, reply) => { const { token } = req.params if (!/^[0-9a-f]{8}$/.test(token)) { @@ -518,7 +674,6 @@ fastify.get('/api/save/:token', async (req, reply) => { const sqlitePath = join(SAVES_DIR, `${token}.sqlite3`) const jsonPath = join(SAVES_DIR, `${token}.json`) - // Case 1: SQLite exists — read from it if (existsSync(sqlitePath)) { try { const pdb = openPlayerDb(token) @@ -531,7 +686,6 @@ fastify.get('/api/save/:token', async (req, reply) => { } } - // Case 2: JSON exists — migrate to SQLite, return migrated state if (existsSync(jsonPath)) { try { const raw = await readFile(jsonPath, 'utf8') @@ -548,13 +702,9 @@ fastify.get('/api/save/:token', async (req, reply) => { } } - // Case 3: Neither exists return reply.code(404).send({ error: 'Save not found' }) }) -// POST /api/save/:token -// Writes player state to SQLite. -// Creates the player database if it does not exist. fastify.post('/api/save/:token', async (req, reply) => { const { token } = req.params if (!/^[0-9a-f]{8}$/.test(token)) { @@ -581,7 +731,7 @@ fastify.setNotFoundHandler((req, reply) => { try { await fastify.listen({ port: 3000, host: '0.0.0.0' }) - console.log('OTIVM server running on port 3000 — OTIVM-IV') + console.log('OTIVM server running on port 3000 — OTIVM-IV (Changes 1+2)') } catch (err) { console.error(err) process.exit(1)