iv: fix drift log trigger type — dispatch_cost vs venture_complete

This commit is contained in:
otivm
2026-05-03 09:27:56 +00:00
parent 40f2e59e14
commit d1e1b98fa5

View File

@@ -1,20 +1,17 @@
// OTIVM server — OTIVM-IV // OTIVM server — OTIVM-III complete
// Per-player SQLite integration. // Per-player SQLite integration.
// //
// Fix 1: if actor_profile exists but background_id = 'unknown' and the // Drift log trigger types:
// incoming body has a real background_id, patch the profile and seed // newDen < prevDen → trigger_type = 'dispatch_cost' (cost deduction on dispatch)
// actor_parameters from background_starting_values. This handles the // newDen > prevDen → trigger_type = 'venture_complete' (return profit)
// race where the first POST (background selection save) arrives before // newAut band change → trigger_type = 'interval_complete' (otium rest)
// 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 split — placeholder pending a proper cost model:
// cost_vectura = 60% of route cost (VECTVRA — freight charge) // cost_vectura = 60% of route cost (VECTVRA — freight charge)
// cost_portoria = 25% of route cost (PORTORIUM — customs duty) // cost_portoria = 25% of route cost (PORTORIUM — customs duty)
// cost_other = 15% of route cost (horreum, incidentals) // cost_other = 15% of route cost (horreum, incidentals)
//
// JSON migration: retained per roadmap — JSON files are never deleted.
import Fastify from 'fastify' import Fastify from 'fastify'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
@@ -116,6 +113,8 @@ function uuid() {
} }
// ── JSON → SQLite migration ────────────────────────────────────────────────── // ── JSON → SQLite migration ──────────────────────────────────────────────────
// Retained per roadmap — JSON files are never deleted.
// Migration runs transparently on first SQLite access if a .json exists.
function migrateJsonToSqlite(token, pdb, json) { function migrateJsonToSqlite(token, pdb, json) {
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -293,9 +292,7 @@ function autToInt(band) {
} }
} }
// ── Seed actor_parameters from background_starting_values ──────────────────── // ── Seed parameters ──────────────────────────────────────────────────────────
// 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) { function seedParameters(pdb, actorId, backgroundId, denOverride, now) {
pdb.prepare(` pdb.prepare(`
@@ -316,12 +313,9 @@ function seedParameters(pdb, actorId, backgroundId, denOverride, now) {
WHERE background_id = ? WHERE background_id = ?
`).run(actorId, now, backgroundId) `).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) { if (denOverride !== undefined) {
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)
pdb.prepare(` pdb.prepare(`
@@ -344,6 +338,14 @@ function findActiveVenture(pdb, actorId) {
`).get(actorId) `).get(actorId)
} }
function findLastCompletedVenture(pdb, actorId) {
return pdb.prepare(`
SELECT venture_id, venture_label FROM ventures
WHERE actor_id = ? AND status = 'complete'
ORDER BY completed_at DESC LIMIT 1
`).get(actorId)
}
function createVenture(pdb, actorId, routeId, now) { function createVenture(pdb, actorId, routeId, now) {
const eco = ROUTE_ECONOMICS[routeId] const eco = ROUTE_ECONOMICS[routeId]
const cargo = ROUTE_CARGO[routeId] const cargo = ROUTE_CARGO[routeId]
@@ -392,8 +394,7 @@ function closeVenture(pdb, actorId, routeId, ventureId, now) {
WHERE venture_id = ? WHERE venture_id = ?
`).run(eco.profit, outcomeNet, now, ventureId) `).run(eco.profit, outcomeNet, now, ventureId)
pdb.prepare(` pdb.prepare(`
UPDATE venture_legs UPDATE venture_legs SET status = 'complete', completed_at = ?
SET status = 'complete', completed_at = ?
WHERE venture_id = ? AND status = 'active' WHERE venture_id = ? AND status = 'active'
`).run(now, ventureId) `).run(now, ventureId)
} }
@@ -420,15 +421,11 @@ function writePlayerState(token, pdb, body) {
const incomingBackground = body.background_id const incomingBackground = body.background_id
const isRealBackground = incomingBackground && incomingBackground !== 'unknown' const isRealBackground = incomingBackground && incomingBackground !== 'unknown'
// Load existing profile (may or may not exist)
const profile = pdb.prepare( const profile = pdb.prepare(
'SELECT actor_id, background_id FROM actor_profile WHERE actor_id = ? LIMIT 1' 'SELECT actor_id, background_id FROM actor_profile WHERE actor_id = ? LIMIT 1'
).get(actorId) ).get(actorId)
if (!profile) { if (!profile) {
// 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 backgroundId = incomingBackground || 'unknown'
const actorName = body.actor_name || 'Mercator' const actorName = body.actor_name || 'Mercator'
@@ -443,18 +440,15 @@ function writePlayerState(token, pdb, body) {
} }
} else if (profile.background_id === 'unknown' && isRealBackground) { } 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(` pdb.prepare(`
UPDATE actor_profile SET background_id = ?, schema_version = 5 WHERE actor_id = ? UPDATE actor_profile SET background_id = ?, schema_version = 5 WHERE actor_id = ?
`).run(incomingBackground, actorId) `).run(incomingBackground, actorId)
seedParameters(pdb, actorId, incomingBackground, body.den, now) seedParameters(pdb, actorId, incomingBackground, body.den, now)
console.log(`[profile] Patched background_id for ${token}: unknown → ${incomingBackground}`) console.log(`[profile] Patched background_id for ${token}: unknown → ${incomingBackground}`)
} }
// Read current parameter values — needed for drift log (Fix 2: null check) // Read current values before any update — null if no prior row exists
const currentLiq = pdb.prepare(` const currentLiq = pdb.prepare(`
SELECT value_true FROM actor_parameters SELECT value_true FROM actor_parameters
WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL WHERE actor_id = ? AND parameter_token = 'liquiditas' AND superseded_at IS NULL
@@ -466,7 +460,6 @@ function writePlayerState(token, pdb, body) {
ORDER BY recorded_at DESC LIMIT 1 ORDER BY recorded_at DESC LIMIT 1
`).get(actorId) `).get(actorId)
// Fix 2: only record drift when a prior row exists (not during initial seeding)
const prevDen = currentLiq?.value_true ?? null const prevDen = currentLiq?.value_true ?? null
const prevAut = currentAut?.value_true ?? null const prevAut = currentAut?.value_true ?? null
@@ -524,18 +517,38 @@ function writePlayerState(token, pdb, body) {
} }
// ── Update parameters + drift log ──────────────────────────────────────── // ── Update parameters + drift log ────────────────────────────────────────
//
// Drift log trigger types:
// newDen < prevDen → 'dispatch_cost' (cost deducted at dispatch)
// newDen > prevDen → 'venture_complete' (profit returned on completion)
// aut band change → 'interval_complete' (otium rest)
//
// No drift log entry when prevDen/prevAut is null (initial seeding).
if (body.den !== undefined) { if (body.den !== undefined) {
const newDen = String(body.den) const newDen = String(body.den)
if (prevDen !== null && newDen !== prevDen) { if (prevDen === null) {
const lastVenture = pdb.prepare(` // Initial write — no drift log
SELECT venture_id, venture_label FROM ventures updateParam(pdb, actorId, 'liquiditas', newDen, newDen, now)
WHERE actor_id = ? AND status = 'complete' } else if (newDen !== prevDen) {
ORDER BY completed_at DESC LIMIT 1 const numNew = parseFloat(newDen)
`).get(actorId) const numPrev = parseFloat(prevDen)
updateParam(pdb, actorId, 'liquiditas', newDen, newDen, now) updateParam(pdb, actorId, 'liquiditas', newDen, newDen, now)
if (numNew < numPrev) {
// Cost deduction — dispatch_start fired
const active = findActiveVenture(pdb, actorId)
writeDriftLog(
pdb, actorId, 'liquiditas',
'dispatch_cost',
active?.venture_id || null,
prevDen, newDen,
active?.venture_label || null,
now
)
} else {
// Profit return — dispatch_complete fired
const lastVenture = findLastCompletedVenture(pdb, actorId)
writeDriftLog( writeDriftLog(
pdb, actorId, 'liquiditas', pdb, actorId, 'liquiditas',
'venture_complete', 'venture_complete',
@@ -544,15 +557,15 @@ function writePlayerState(token, pdb, body) {
lastVenture?.venture_label || null, lastVenture?.venture_label || null,
now 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) { if (body.aut !== undefined) {
const newBand = autBand(body.aut) const newBand = autBand(body.aut)
if (prevAut !== null && newBand !== prevAut) { if (prevAut === null) {
updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now)
} else if (newBand !== prevAut) {
updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now) updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now)
writeDriftLog( writeDriftLog(
pdb, actorId, 'auctoritas', pdb, actorId, 'auctoritas',
@@ -562,8 +575,6 @@ function writePlayerState(token, pdb, body) {
'otium rest', 'otium rest',
now now
) )
} else if (prevAut === null) {
updateParamWithSocial(pdb, actorId, 'auctoritas', newBand, newBand, newBand, now)
} }
} }
} }
@@ -696,7 +707,7 @@ fastify.setNotFoundHandler((req, reply) => { reply.sendFile('index.html') })
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 (fix: background_id patch)') console.log('OTIVM server running on port 3000 — OTIVM-III complete')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
process.exit(1) process.exit(1)