feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
This commit is contained in:
+129
-1
@@ -6,9 +6,10 @@ import { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
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, type WorldState } from './save'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
|
||||
import type { Task } from './tasks'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
@@ -439,6 +440,133 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- runTurn: the DB-side tick (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.
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
const count = (t: typeof schema.solarSystems | typeof schema.planets | typeof schema.inventoryItems | typeof schema.missions | typeof schema.events | typeof schema.tasks) => db.db.select().from(t).then((rows) => rows.length)
|
||||
const before = {
|
||||
systems: await count(schema.solarSystems),
|
||||
planets: await count(schema.planets),
|
||||
inventory: await count(schema.inventoryItems),
|
||||
missions: await count(schema.missions),
|
||||
events: await count(schema.events),
|
||||
}
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.turn).toBe(1)
|
||||
expect(r.tasks).toEqual([])
|
||||
expect(r.completed).toEqual([])
|
||||
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
expect(save.turn).toBe(1) // the tick is the increment — the only write of the call
|
||||
expect(await count(schema.solarSystems)).toBe(before.systems)
|
||||
expect(await count(schema.planets)).toBe(before.planets)
|
||||
expect(await count(schema.inventoryItems)).toBe(before.inventory)
|
||||
expect(await count(schema.missions)).toBe(before.missions) // no top-up without a resolution
|
||||
expect(await count(schema.events)).toBe(before.events) // no event for a skip
|
||||
expect(await count(schema.tasks)).toBe(0)
|
||||
})
|
||||
|
||||
it('a resolved JUMP completes the matching DELIVER mission, grants the reward, and tops the board up — the 4a hooks, at the tick\'s new turn', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db) // s0 current; s1 'Outpost' undiscovered
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 4 })
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.update(schema.saves).set({ turn: 5 }).where(eq(schema.saves.id, save.id))
|
||||
const [mission] = await db.db.insert(schema.missions).values({ saveId: save.id, type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: f.sys1, progressTarget: 1, rewardKind: 'resource', rewardQty: 2, state: 'ACTIVE' }).returning({ id: schema.missions.id })
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.turn).toBe(6)
|
||||
expect(r.completed.map((t) => t.id)).toEqual([jumpId])
|
||||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === jumpId)!.status).toBe('DONE')
|
||||
|
||||
// the mission resolved at the new turn: DONE + reward in the hold + both events at turn 6
|
||||
expect((await db.db.select().from(schema.missions)).find((m) => m.id === mission.id)).toMatchObject({ state: 'DONE', progress: 1 })
|
||||
expect(await db.db.select().from(schema.inventoryItems)).toEqual([{ id: expect.any(Number), saveId: save.id, kind: 'resource', name: 'MISSION REWARD', qty: 2 }])
|
||||
const events = (await db.db.select().from(schema.events)).map((e) => [e.turn, e.type] as const)
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events).toContainEqual([6, 'jump'])
|
||||
expect(events).toContainEqual([6, 'mission'])
|
||||
|
||||
// the board is topped up after the arrival (the 4a jump-resolver hook) to exactly the floor
|
||||
const available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
|
||||
expect(available).toBe(MIN_AVAILABLE)
|
||||
|
||||
// the effect half: the ship is at the payload system
|
||||
expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true)
|
||||
})
|
||||
|
||||
it('a JUMP and an EXPLORE both resolving on one tick: the explore effect lands on its payload planet, not the newly arrived system (Review Focus 3)', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db) // p0 'Alder' in s0 (Origin); p1 'Basalt' in s1 (Outpost)
|
||||
const [c1, c2] = await addCrew(db, 2)
|
||||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE', 'JUMP']) // both this tick — the resolvers are payload-targeted (4a), so resolution order cannot change what lands
|
||||
|
||||
const planets = await db.db.select().from(schema.planets)
|
||||
expect(planets.find((p) => p.id === f.p0)!.explored).toBe(true) // the payload planet — in the OLD system
|
||||
expect(planets.find((p) => p.id === f.p1)!.explored).toBe(false) // nothing in the new system
|
||||
expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) // the jump still landed
|
||||
// the haul is the payload planet's deterministic stream (seed '42', p0) — never the new system's
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind, name: i.name, qty: i.qty }))).toEqual(rollExploration(explorationRng('42', f.p0)))
|
||||
// the ~18% crew roll rides the same stream — at most one member joins (PR C pins the stream)
|
||||
expect((await db.db.select().from(schema.crew)).length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('a resolver guard failure fails just that task — the turn still lands, the sibling still resolves, no event for the failure', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [c1, c2] = await addCrew(db, 2)
|
||||
const badJumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: 999, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
|
||||
|
||||
const r = await runTurn(db) // must not throw — the 4a guard throw is a failure signal, not an error
|
||||
expect(r.turn).toBe(5)
|
||||
expect(r.completed.map((t) => t.id)).toEqual([badJumpId, exploreId]) // both reported; only the sibling's effect lands
|
||||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === badJumpId)!.status).toBe('DONE') // failed task: done — the turn is spent, the crew freed
|
||||
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(true) // the sibling resolved
|
||||
const types = (await db.db.select().from(schema.events)).map((e) => e.type)
|
||||
expect(types.filter((t) => t === 'jump')).toHaveLength(0) // the 404 guard threw before the jump effect/event
|
||||
expect(types.filter((t) => t === 'explore')).toHaveLength(1)
|
||||
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
|
||||
})
|
||||
})
|
||||
|
||||
describe('backfill (old saves)', () => {
|
||||
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
|
||||
// regenerated set compares equal to the original even though autoincrement moved on.
|
||||
|
||||
Reference in New Issue
Block a user