feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)

This commit is contained in:
2026-10-02 15:47:31 +00:00
parent d7faf739a9
commit 4e312d054f
10 changed files with 455 additions and 66 deletions
+8 -35
View File
@@ -10,6 +10,7 @@ import { MIN_AVAILABLE } from './missions'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech'
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
import type { Task } from './tasks'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
@@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => {
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
//
// Each test builds the identical deterministic fixture in two fresh files (seed '42',
// stamped save → no backfill; ids line up across worlds) and compares their full state:
// Each test builds the identical deterministic fixture (buildWorldFixture — the shared
// fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up
// across worlds) in two fresh files and compares their full state:
// A = the legacy immediate action (validates, costs a turn, applies the effect);
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
// excluded), so the two end states must be byte-identical.
const T0 = '2026-10-01T00:00:00.000Z'
async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
return { sys1: s1.id, p0: p0.id, p1: p1.id }
}
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
// identity fields, the same way the public functions' delegation does.
@@ -374,7 +364,7 @@ describe('resolveJumpTask (A/B against jumpToSystem)', () => {
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
expect(await snapshot(b)).toEqual(await snapshot(a))
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added
})
it('guards: 404 unknown system, 409 already-current', async () => {
@@ -447,26 +437,9 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
// 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.
describe('runTurn (tick on the DB)', () => {
// buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no
// backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) /
// Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so
// this block adds its own rows.
async function addCrew(db: GameDb, count: number): Promise<number[]> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const ids: number[] = []
for (let i = 0; i < count; i++) {
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
ids.push(row.id)
}
return ids
}
async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id })
return row.id
}
// buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin /
// Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick
// needs both, so each test here mints its own rows via addCrew / insertTaskRow.
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)