iv: wire ventures, venture_legs, and parameter_drift_log (Changes 1+2)
This commit is contained in:
386
server/index.js
386
server/index.js
@@ -8,18 +8,26 @@
|
|||||||
// transparently on first SQLite access if present.
|
// transparently on first SQLite access if present.
|
||||||
// Frontend interface unchanged — GET/POST /api/save/:token same as before.
|
// Frontend interface unchanged — GET/POST /api/save/:token same as before.
|
||||||
//
|
//
|
||||||
// Change 3 (OTIVM-IV): background_id from the frontend is now a real
|
// Change 1 (OTIVM-IV): on dispatch_start event, create ventures +
|
||||||
// canonical identifier (e.g. 'former_legionary'). On new player creation,
|
// venture_legs rows. On dispatch_complete, close venture with outcome_net.
|
||||||
// seed actor_parameters from background_starting_values for that background.
|
// Change 2 (OTIVM-IV): on den change, write parameter_drift_log row
|
||||||
// The previous default of 'unknown' is kept only as a fallback for legacy
|
// (trigger_type = 'venture_complete'). On aut change, write
|
||||||
// saves that predate Change 3.
|
// 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 Fastify from 'fastify'
|
||||||
import fastifyStatic from '@fastify/static'
|
import fastifyStatic from '@fastify/static'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, mkdir } from 'fs/promises'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -31,6 +39,51 @@ const SCHEMA_PATH = join(ROOT, 'data', 'create_player_db.sql')
|
|||||||
|
|
||||||
await mkdir(SAVES_DIR, { recursive: true })
|
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) ──────────────────────────────────────
|
// ── TESSERA world database (read-only) ──────────────────────────────────────
|
||||||
|
|
||||||
const db = new Database(DB_PATH, { readonly: true })
|
const db = new Database(DB_PATH, { readonly: true })
|
||||||
@@ -56,19 +109,9 @@ function h3HexToInt(hexStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-player database ──────────────────────────────────────────────────────
|
// ── 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')
|
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) {
|
function openPlayerDb(token) {
|
||||||
const path = join(SAVES_DIR, `${token}.sqlite3`)
|
const path = join(SAVES_DIR, `${token}.sqlite3`)
|
||||||
const isNew = !existsSync(path)
|
const isNew = !existsSync(path)
|
||||||
@@ -76,38 +119,38 @@ function openPlayerDb(token) {
|
|||||||
pdb.pragma('journal_mode = WAL')
|
pdb.pragma('journal_mode = WAL')
|
||||||
pdb.pragma('foreign_keys = ON')
|
pdb.pragma('foreign_keys = ON')
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// Run the full schema — idempotent due to IF NOT EXISTS clauses.
|
|
||||||
pdb.exec(PLAYER_SCHEMA_SQL)
|
pdb.exec(PLAYER_SCHEMA_SQL)
|
||||||
}
|
}
|
||||||
return pdb
|
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 ──────────────────────────────────────────────────
|
// ── 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) {
|
function migrateJsonToSqlite(token, pdb, json) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const actorId = token
|
const actorId = token
|
||||||
const sessionId = token
|
const sessionId = token
|
||||||
|
|
||||||
// actor_profile — one row anchoring the actor
|
|
||||||
const background = json.background_id || 'unknown'
|
const background = json.background_id || 'unknown'
|
||||||
const name = json.actor_name || 'Mercator'
|
const name = json.actor_name || 'Mercator'
|
||||||
const epoch = 'roman_14bce'
|
const epoch = 'roman_14bce'
|
||||||
|
|
||||||
const insertProfile = pdb.prepare(`
|
pdb.prepare(`
|
||||||
INSERT OR IGNORE INTO actor_profile
|
INSERT OR IGNORE INTO actor_profile
|
||||||
(actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at)
|
(actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 3, ?)
|
VALUES (?, ?, ?, ?, ?, 5, ?)
|
||||||
`)
|
`).run(actorId, sessionId, background, name, epoch, now)
|
||||||
insertProfile.run(actorId, sessionId, background, name, epoch, now)
|
|
||||||
|
|
||||||
// Seed actor_parameters from background_starting_values if not already seeded
|
pdb.prepare(`
|
||||||
const seedParams = pdb.prepare(`
|
|
||||||
INSERT OR IGNORE INTO actor_parameters
|
INSERT OR IGNORE INTO actor_parameters
|
||||||
(actor_id, parameter_token, scope, layer,
|
(actor_id, parameter_token, scope, layer,
|
||||||
value_true, value_perceived, value_social,
|
value_true, value_perceived, value_social,
|
||||||
@@ -122,43 +165,36 @@ function migrateJsonToSqlite(token, pdb, json) {
|
|||||||
confidence_tag, observable_level, 'migration', ?
|
confidence_tag, observable_level, 'migration', ?
|
||||||
FROM background_starting_values
|
FROM background_starting_values
|
||||||
WHERE background_id = ?
|
WHERE background_id = ?
|
||||||
`)
|
`).run(actorId, now, background)
|
||||||
seedParams.run(actorId, now, background)
|
|
||||||
|
|
||||||
// Override liquiditas with actual value from JSON if available
|
|
||||||
if (json.den !== undefined) {
|
if (json.den !== undefined) {
|
||||||
const upsertLiq = pdb.prepare(`
|
pdb.prepare(`
|
||||||
INSERT INTO actor_parameters
|
INSERT INTO actor_parameters
|
||||||
(actor_id, parameter_token, scope, layer,
|
(actor_id, parameter_token, scope, layer,
|
||||||
value_true, value_perceived, confidence_tag, observable_level,
|
value_true, value_perceived, confidence_tag, observable_level,
|
||||||
drift_source, recorded_at)
|
drift_source, recorded_at)
|
||||||
VALUES (?, 'liquiditas', 'actor', 'roman',
|
VALUES (?, 'liquiditas', 'actor', 'roman',
|
||||||
?, ?, 'measured', 'full', 'migration', ?)
|
?, ?, 'measured', 'full', 'migration', ?)
|
||||||
`)
|
`).run(actorId, String(json.den), String(json.den), now)
|
||||||
upsertLiq.run(actorId, String(json.den), String(json.den), now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override auctoritas with actual value from JSON if available
|
|
||||||
if (json.aut !== undefined) {
|
if (json.aut !== undefined) {
|
||||||
const upsertAut = pdb.prepare(`
|
const band = autBand(json.aut)
|
||||||
|
pdb.prepare(`
|
||||||
INSERT INTO actor_parameters
|
INSERT INTO actor_parameters
|
||||||
(actor_id, parameter_token, scope, layer,
|
(actor_id, parameter_token, scope, layer,
|
||||||
value_true, value_perceived, value_social,
|
value_true, value_perceived, value_social,
|
||||||
confidence_tag, observable_level, drift_source, recorded_at)
|
confidence_tag, observable_level, drift_source, recorded_at)
|
||||||
VALUES (?, 'auctoritas', 'actor', 'roman',
|
VALUES (?, 'auctoritas', 'actor', 'roman',
|
||||||
?, ?, ?, 'indicated', 'partial', 'migration', ?)
|
?, ?, ?, 'indicated', 'partial', 'migration', ?)
|
||||||
`)
|
`).run(actorId, band, band, band, now)
|
||||||
const band = autBand(json.aut)
|
|
||||||
upsertAut.run(actorId, band, band, band, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import legacy events[] array into the events table
|
|
||||||
const insertEvent = pdb.prepare(`
|
const insertEvent = pdb.prepare(`
|
||||||
INSERT INTO events (actor_id, event_type, ref_id, ref_type, payload, recorded_at)
|
INSERT INTO events (actor_id, event_type, ref_id, ref_type, payload, recorded_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
const eventsArr = json.events || []
|
for (const ev of (json.events || [])) {
|
||||||
for (const ev of eventsArr) {
|
|
||||||
insertEvent.run(
|
insertEvent.run(
|
||||||
actorId,
|
actorId,
|
||||||
ev.type || 'unknown',
|
ev.type || 'unknown',
|
||||||
@@ -169,18 +205,13 @@ function migrateJsonToSqlite(token, pdb, json) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration itself as an event
|
|
||||||
insertEvent.run(
|
insertEvent.run(
|
||||||
actorId,
|
actorId, 'session_start', null, null,
|
||||||
'session_start',
|
JSON.stringify({ source: 'json_migration', schema_version: 5 }),
|
||||||
null,
|
|
||||||
null,
|
|
||||||
JSON.stringify({ source: 'json_migration', schema_version: 3 }),
|
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert raw auctoritas integer (OTIVM-I/II) to ordinal band.
|
|
||||||
function autBand(aut) {
|
function autBand(aut) {
|
||||||
if (aut >= 30) return 'distinguished'
|
if (aut >= 30) return 'distinguished'
|
||||||
if (aut >= 15) return 'high'
|
if (aut >= 15) return 'high'
|
||||||
@@ -188,14 +219,9 @@ function autBand(aut) {
|
|||||||
return 'low'
|
return 'low'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read player state from SQLite ────────────────────────────────────────────
|
// ── Read player state ────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
function readPlayerState(token, pdb) {
|
function readPlayerState(token, pdb) {
|
||||||
// Current parameter values — most recent non-superseded row per token
|
|
||||||
const params = pdb.prepare(`
|
const params = pdb.prepare(`
|
||||||
SELECT parameter_token, value_true, value_perceived, value_social,
|
SELECT parameter_token, value_true, value_perceived, value_social,
|
||||||
confidence_tag, observable_level
|
confidence_tag, observable_level
|
||||||
@@ -213,7 +239,6 @@ function readPlayerState(token, pdb) {
|
|||||||
const autStr = paramMap['auctoritas']?.value_true || 'low'
|
const autStr = paramMap['auctoritas']?.value_true || 'low'
|
||||||
const aut = autToInt(autStr)
|
const aut = autToInt(autStr)
|
||||||
|
|
||||||
// Events — chronological
|
|
||||||
const events = pdb.prepare(`
|
const events = pdb.prepare(`
|
||||||
SELECT event_type AS type, ref_id AS route_id, payload, recorded_at AS timestamp_utc
|
SELECT event_type AS type, ref_id AS route_id, payload, recorded_at AS timestamp_utc
|
||||||
FROM events
|
FROM events
|
||||||
@@ -221,7 +246,6 @@ function readPlayerState(token, pdb) {
|
|||||||
ORDER BY recorded_at ASC
|
ORDER BY recorded_at ASC
|
||||||
`).all(token)
|
`).all(token)
|
||||||
|
|
||||||
// Derive dispatches and route_dispatches from event log
|
|
||||||
let dispatches = 0
|
let dispatches = 0
|
||||||
const route_dispatches = {}
|
const route_dispatches = {}
|
||||||
const journal_seen = []
|
const journal_seen = []
|
||||||
@@ -254,13 +278,11 @@ function readPlayerState(token, pdb) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chapter derived from den + aut
|
|
||||||
if (aut >= 30 && den >= 800) chapter = 5
|
if (aut >= 30 && den >= 800) chapter = 5
|
||||||
else if (aut >= 15 && den >= 350) chapter = 4
|
else if (aut >= 15 && den >= 350) chapter = 4
|
||||||
else if (aut >= 5 && den >= 120) chapter = 3
|
else if (aut >= 5 && den >= 120) chapter = 3
|
||||||
else if (den >= 40) chapter = 2
|
else if (den >= 40) chapter = 2
|
||||||
|
|
||||||
// actor_profile
|
|
||||||
const profile = pdb.prepare(
|
const profile = pdb.prepare(
|
||||||
'SELECT * FROM actor_profile WHERE actor_id = ? ORDER BY recorded_at DESC LIMIT 1'
|
'SELECT * FROM actor_profile WHERE actor_id = ? ORDER BY recorded_at DESC LIMIT 1'
|
||||||
).get(token)
|
).get(token)
|
||||||
@@ -282,11 +304,10 @@ function readPlayerState(token, pdb) {
|
|||||||
timestamp_utc: e.timestamp_utc,
|
timestamp_utc: e.timestamp_utc,
|
||||||
})),
|
})),
|
||||||
created_at: profile?.recorded_at || new Date().toISOString(),
|
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) {
|
function autToInt(band) {
|
||||||
switch (band) {
|
switch (band) {
|
||||||
case 'distinguished': return 35
|
case 'distinguished': return 35
|
||||||
@@ -296,19 +317,95 @@ function autToInt(band) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Write player state to SQLite ─────────────────────────────────────────────
|
// ── Venture helpers ──────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Receives the flat JSON game state from the frontend.
|
// Find the open (active) venture for this actor, if any.
|
||||||
// Writes parameter changes as new actor_parameters rows (append-only).
|
function findActiveVenture(pdb, actorId) {
|
||||||
// Appends new events to the events table.
|
return pdb.prepare(`
|
||||||
// Marks superseded parameter rows.
|
SELECT venture_id, venture_label FROM ventures
|
||||||
//
|
WHERE actor_id = ? AND status = 'active'
|
||||||
// On new player creation (no actor_profile row yet):
|
ORDER BY recorded_at DESC LIMIT 1
|
||||||
// - Uses background_id from the request body (sent by frontend after
|
`).get(actorId)
|
||||||
// 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.
|
// Create a venture + venture_leg row on dispatch_start.
|
||||||
// - Falls back to 'unknown' only if no background_id is present (legacy saves).
|
// 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) {
|
function writePlayerState(token, pdb, body) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -320,19 +417,15 @@ function writePlayerState(token, pdb, body) {
|
|||||||
).get(actorId)
|
).get(actorId)
|
||||||
|
|
||||||
if (!profile) {
|
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 backgroundId = body.background_id || 'unknown'
|
||||||
const actorName = body.actor_name || 'Mercator'
|
const actorName = body.actor_name || 'Mercator'
|
||||||
|
|
||||||
pdb.prepare(`
|
pdb.prepare(`
|
||||||
INSERT OR IGNORE INTO actor_profile
|
INSERT OR IGNORE INTO actor_profile
|
||||||
(actor_id, session_id, background_id, actor_name, epoch, schema_version, recorded_at)
|
(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)
|
`).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(`
|
pdb.prepare(`
|
||||||
INSERT OR IGNORE INTO actor_parameters
|
INSERT OR IGNORE INTO actor_parameters
|
||||||
(actor_id, parameter_token, scope, layer,
|
(actor_id, parameter_token, scope, layer,
|
||||||
@@ -351,17 +444,12 @@ function writePlayerState(token, pdb, body) {
|
|||||||
WHERE background_id = ?
|
WHERE background_id = ?
|
||||||
`).run(actorId, now, backgroundId)
|
`).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) {
|
if (body.den !== undefined) {
|
||||||
// Supersede the seed row
|
|
||||||
pdb.prepare(`
|
pdb.prepare(`
|
||||||
UPDATE actor_parameters
|
UPDATE actor_parameters
|
||||||
SET superseded_at = ?
|
SET superseded_at = ?
|
||||||
WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL
|
WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL
|
||||||
`).run(now, actorId)
|
`).run(now, actorId)
|
||||||
// Insert the confirmed starting value
|
|
||||||
pdb.prepare(`
|
pdb.prepare(`
|
||||||
INSERT INTO actor_parameters
|
INSERT INTO actor_parameters
|
||||||
(actor_id, parameter_token, scope, layer,
|
(actor_id, parameter_token, scope, layer,
|
||||||
@@ -373,18 +461,23 @@ function writePlayerState(token, pdb, body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update liquiditas if den changed
|
// Read current den/aut before any update — needed for drift log
|
||||||
if (body.den !== undefined) {
|
const currentLiq = pdb.prepare(`
|
||||||
updateParam(pdb, actorId, 'liquiditas', String(body.den), String(body.den), now)
|
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
|
const prevDen = currentLiq?.value_true ?? '0'
|
||||||
if (body.aut !== undefined) {
|
const prevAut = currentAut?.value_true ?? 'low'
|
||||||
const band = autBand(body.aut)
|
|
||||||
updateParamWithSocial(pdb, actorId, 'auctoritas', band, band, band, now)
|
// ── Process new events (Change 1 + Change 2) ─────────────────────────────
|
||||||
}
|
|
||||||
|
|
||||||
// Append new events — only those not already recorded
|
|
||||||
const lastRecorded = pdb.prepare(`
|
const lastRecorded = pdb.prepare(`
|
||||||
SELECT recorded_at FROM events
|
SELECT recorded_at FROM events
|
||||||
WHERE actor_id = ?
|
WHERE actor_id = ?
|
||||||
@@ -399,20 +492,93 @@ function writePlayerState(token, pdb, body) {
|
|||||||
|
|
||||||
for (const ev of (body.events || [])) {
|
for (const ev of (body.events || [])) {
|
||||||
const ts = ev.timestamp_utc || now
|
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, 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(
|
insertEvent.run(
|
||||||
actorId,
|
actorId,
|
||||||
ev.type || 'unknown',
|
ev.type || 'unknown',
|
||||||
ev.route_id || null,
|
routeId,
|
||||||
ev.route_id ? 'venture' : null,
|
routeId ? 'venture' : null,
|
||||||
JSON.stringify(ev),
|
JSON.stringify(ev),
|
||||||
ts
|
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) {
|
function updateParam(pdb, actorId, token, valueTrue, valuePerceived, now) {
|
||||||
pdb.prepare(`
|
pdb.prepare(`
|
||||||
UPDATE actor_parameters
|
UPDATE actor_parameters
|
||||||
@@ -454,11 +620,6 @@ await fastify.register(fastifyStatic, {
|
|||||||
prefix: '/',
|
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) => {
|
fastify.get('/api/map/:h5/:epoch', async (req, reply) => {
|
||||||
const { h5: h5param, epoch: epochKey } = req.params
|
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}` })
|
return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const slOffsetCm = epoch.sl_offset_cm
|
|
||||||
|
|
||||||
let h5int
|
let h5int
|
||||||
try {
|
try {
|
||||||
h5int = h3HexToInt(h5param)
|
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' })
|
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) {
|
if (!rows.length) {
|
||||||
return reply.code(404).send({ error: `No data for H5: ${h5param}` })
|
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({
|
return reply.send({
|
||||||
epoch_key: epochKey,
|
epoch_key: epochKey,
|
||||||
sl_offset_cm: slOffsetCm,
|
sl_offset_cm: epoch.sl_offset_cm,
|
||||||
h5: h5param,
|
h5: h5param,
|
||||||
cells,
|
cells,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET /api/save/:token
|
|
||||||
// Returns the current player state.
|
|
||||||
// Priority: SQLite > JSON migration > 404.
|
|
||||||
fastify.get('/api/save/:token', async (req, reply) => {
|
fastify.get('/api/save/:token', async (req, reply) => {
|
||||||
const { token } = req.params
|
const { token } = req.params
|
||||||
if (!/^[0-9a-f]{8}$/.test(token)) {
|
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 sqlitePath = join(SAVES_DIR, `${token}.sqlite3`)
|
||||||
const jsonPath = join(SAVES_DIR, `${token}.json`)
|
const jsonPath = join(SAVES_DIR, `${token}.json`)
|
||||||
|
|
||||||
// Case 1: SQLite exists — read from it
|
|
||||||
if (existsSync(sqlitePath)) {
|
if (existsSync(sqlitePath)) {
|
||||||
try {
|
try {
|
||||||
const pdb = openPlayerDb(token)
|
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)) {
|
if (existsSync(jsonPath)) {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(jsonPath, 'utf8')
|
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' })
|
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) => {
|
fastify.post('/api/save/:token', async (req, reply) => {
|
||||||
const { token } = req.params
|
const { token } = req.params
|
||||||
if (!/^[0-9a-f]{8}$/.test(token)) {
|
if (!/^[0-9a-f]{8}$/.test(token)) {
|
||||||
@@ -581,7 +731,7 @@ fastify.setNotFoundHandler((req, reply) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fastify.listen({ port: 3000, host: '0.0.0.0' })
|
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) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user