feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)
This commit is contained in:
+128
-1
@@ -6,7 +6,10 @@ import { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship'
|
||||
import { buildSystem, expandSystem, getShipState, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, spendItems, upgradeSystem, type ShipState } from './ship'
|
||||
import { SCHEMA_VERSION } from './save'
|
||||
import { TECHS } from './tech'
|
||||
import type { Task } from './tasks'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
@@ -155,3 +158,127 @@ describe('buildSystem', () => {
|
||||
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
|
||||
})
|
||||
})
|
||||
|
||||
// --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) ---------------------
|
||||
const T0 = '2026-10-01T00:00:00.000Z'
|
||||
|
||||
// Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with
|
||||
// WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up.
|
||||
async function buildShipFixture(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 SHIP_TYPES) {
|
||||
await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
|
||||
}
|
||||
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, researched: def.slug === 'WEAPONS_SYSTEMS' })
|
||||
}
|
||||
}
|
||||
|
||||
const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', 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('resolveShip* (A/B against the immediate actions)', () => {
|
||||
// A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event).
|
||||
// B = the queued shape: the cost is deducted up front (queueTask did it at queue time —
|
||||
// Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect.
|
||||
// Same starting world → byte-identical end states.
|
||||
|
||||
it('UPGRADE: lands exactly what upgradeSystem lands', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
await buildShipFixture(a)
|
||||
await buildShipFixture(b)
|
||||
await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1
|
||||
await addItem(b, 'resource', 'IRON ORE', 1)
|
||||
|
||||
const reactorId = systemOf(await getShipState(a), 'REACTOR').id
|
||||
await upgradeSystem(a, reactorId)
|
||||
await spendItems(b, 1, 'resource', 1) // the queue-time deduction
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
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 === 'upgrade')!
|
||||
expect(ev.turn).toBe(1)
|
||||
expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)')
|
||||
})
|
||||
|
||||
it('EXPAND: lands exactly what expandSystem lands', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
await buildShipFixture(a)
|
||||
await buildShipFixture(b)
|
||||
await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2
|
||||
await addItem(b, 'resource', 'CRYSTAL DUST', 2)
|
||||
|
||||
const hullId = systemOf(await getShipState(a), 'HULL').id
|
||||
await expandSystem(a, hullId)
|
||||
await spendItems(b, 1, 'resource', 2)
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
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 === 'expand')!
|
||||
expect(ev.turn).toBe(1)
|
||||
expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)')
|
||||
})
|
||||
|
||||
it('BUILD: lands exactly what buildSystem lands', async () => {
|
||||
const { db: a } = await freshFile()
|
||||
const { db: b } = await freshFile()
|
||||
await buildShipFixture(a)
|
||||
await buildShipFixture(b)
|
||||
await addItem(a, 'part', 'SERVO ACTUATOR', 3)
|
||||
await addItem(b, 'part', 'SERVO ACTUATOR', 3)
|
||||
|
||||
await buildSystem(a, 'WEAPONS')
|
||||
await spendItems(b, 1, 'part', 3)
|
||||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' }))
|
||||
|
||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||
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 === 'build')!
|
||||
expect(ev.turn).toBe(1)
|
||||
expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)')
|
||||
})
|
||||
|
||||
it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildShipFixture(db)
|
||||
const state = await getShipState(db)
|
||||
const reactorId = systemOf(state, 'REACTOR').id
|
||||
const hullId = systemOf(state, 'HULL').id
|
||||
const weaponsId = systemOf(state, 'WEAPONS').id
|
||||
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||
|
||||
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
|
||||
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' })
|
||||
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId))
|
||||
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` })
|
||||
|
||||
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
|
||||
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` })
|
||||
|
||||
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' })
|
||||
await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
|
||||
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' })
|
||||
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user