feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
This commit is contained in:
+79
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user