feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)

This commit is contained in:
2026-10-02 13:55:32 +00:00
parent d4491e082e
commit bbcd77c41b
6 changed files with 521 additions and 60 deletions
+75 -2
View File
@@ -6,9 +6,11 @@ import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs'
import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship'
import { buildSystem, getShipState, spendItems, upgradeSystem, type ShipState } from './ship'
import { TECHS } from './tech'
import { getTechState, researchTech } from './research'
import { SCHEMA_VERSION } from './save'
import { getTechState, researchTech, resolveResearchTask } from './research'
import type { Task } from './tasks'
const dirs: string[] = []
@@ -145,3 +147,74 @@ describe('gating of ship actions', () => {
expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1)
})
})
// --- A/B: resolver vs the legacy immediate action (Task 4a) ---------------------------
const T0 = '2026-10-01T00:00:00.000Z'
// Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build
// per world → ids line up.
async function buildResearchFixture(db: GameDb): Promise<void> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
for (const def of TECHS) {
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
}
}
const task = (payload: object): Task =>
({ id: 0, saveId: 0, type: 'RESEARCH', 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('resolveResearchTask (A/B against researchTech)', () => {
// A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched,
// event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick
// advanced the turn (Task 4b), then the resolver marks the tech. Same starting world →
// byte-identical end states.
it('lands exactly what researchTech lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildResearchFixture(a)
await buildResearchFixture(b)
await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources
await addItem(b, 'resource', 'IRON ORE', 2)
const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await researchTech(a, navA.id)
await spendItems(b, 1, 'resource', 2) // the queue-time deduction
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveResearchTask(b, task({ techId: navB.id }))
expect(await snapshot(b)).toEqual(await snapshot(a))
expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true)
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 === 'research')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)')
})
it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => {
const { db } = await freshFile()
await buildResearchFixture(db)
const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' })
await resolveResearchTask(db, task({ techId: nav.id }))
await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' })
})
})