feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)
This commit is contained in:
+136
-1
@@ -8,7 +8,8 @@ import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { TECHS } from './tech'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, type WorldState } from './save'
|
||||
import type { Task } from './tasks'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -304,6 +305,140 @@ 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:
|
||||
// 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.
|
||||
const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task =>
|
||||
({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
|
||||
|
||||
async function snapshot(db: GameDb): Promise<string> {
|
||||
const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
|
||||
db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
|
||||
db.db.select().from(schema.solarSystems),
|
||||
db.db.select().from(schema.jumpRoutes),
|
||||
db.db.select().from(schema.planets),
|
||||
db.db.select().from(schema.inventoryItems),
|
||||
db.db.select().from(schema.crew),
|
||||
db.db.select().from(schema.shipSystems),
|
||||
db.db.select().from(schema.tech),
|
||||
db.db.select().from(schema.missions),
|
||||
db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
|
||||
])
|
||||
return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
|
||||
}
|
||||
|
||||
describe('resolveJumpTask (A/B against jumpToSystem)', () => {
|
||||
it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
const fa = await buildWorldFixture(a)
|
||||
const fb = await buildWorldFixture(b)
|
||||
|
||||
await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b)
|
||||
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
})
|
||||
|
||||
it('discovered target: no ring — still byte-identical to jumpToSystem', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
const fa = await buildWorldFixture(a, true)
|
||||
const fb = await buildWorldFixture(b, true)
|
||||
|
||||
await jumpToSystem(a, fa.sys1)
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
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
|
||||
})
|
||||
|
||||
it('guards: 404 unknown system, 409 already-current', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
|
||||
await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' })
|
||||
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1))
|
||||
await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1))
|
||||
await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveExploreTask (A/B against explorePlanet)', () => {
|
||||
it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
const fa = await buildWorldFixture(a)
|
||||
const fb = await buildWorldFixture(b)
|
||||
|
||||
await explorePlanet(a, fa.p0)
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
})
|
||||
|
||||
it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
const fa = await buildWorldFixture(a)
|
||||
const fb = await buildWorldFixture(b)
|
||||
|
||||
await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
|
||||
const sysB = await b.db.select().from(schema.solarSystems)
|
||||
expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved
|
||||
expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system
|
||||
|
||||
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')!
|
||||
expect(ev.turn).toBe(1)
|
||||
// Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream.
|
||||
const rng = explorationRng('42', fb.p1)
|
||||
const haul = rollExploration(rng)
|
||||
const member = rollCrewMember(rng, new Set<string>())
|
||||
const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
||||
expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`)
|
||||
})
|
||||
|
||||
it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
|
||||
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' })
|
||||
await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
|
||||
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
|
||||
})
|
||||
})
|
||||
|
||||
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