feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)

This commit is contained in:
2026-10-02 14:30:49 +00:00
parent bbcd77c41b
commit d7faf739a9
4 changed files with 385 additions and 14 deletions
+79 -1
View File
@@ -5,7 +5,9 @@ import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
import type { Task, TaskType } from './tasks'
import { resolveResearchTask, ResearchError } from './research'
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
@@ -435,3 +437,79 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
return { ...(await readSystemState(db)), outcome, crewFound }
}
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
//
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
// before the final persist); this function owns the table writes.
// The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
// a TaskType without a resolver is a compile error.
const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
JUMP: resolveJumpTask,
EXPLORE: resolveExploreTask,
SHIP_UPGRADE: resolveShipUpgradeTask,
SHIP_EXPAND: resolveShipExpandTask,
SHIP_BUILD: resolveShipBuildTask,
RESEARCH: resolveResearchTask,
}
// Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the
// routes' queueing path).
function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task {
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
}
export async function runTurn(db: GameDb): Promise<TickResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
db.db.select().from(schema.crew),
db.db.select().from(schema.tasks),
db.db.select().from(schema.inventoryItems),
db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech),
])
const state: QueueState = {
turn: save.turn,
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
tasks: taskRows.map(rowToTask),
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
}
const result = tickTurn(state)
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
// and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new
// turn, exactly where today's actions place them.
await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id))
// (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard
// (stale or double-resolved target): the task fails — it is already DONE (the tick freed
// the team; the turn is spent), the effect simply does not land, and no event is written
// for it. Only the four domain error classes fail a task; anything else rethrows.
for (const task of result.completed) {
try {
await TASK_RESOLVERS[task.type](db, task)
} catch (err) {
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
throw err
}
}
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
// one final whole-DB write makes the turn atomic on disk.
for (const before of state.tasks) {
const after = result.tasks.find((t) => t.id === before.id)
if (after && (after.status !== before.status || after.remaining !== before.remaining)) {
await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id))
}
}
await persist(db)
return result
}