game: ship actions — upgrade/expand/build with cost model, validation (404/409/422), turn + event per action

This commit is contained in:
2026-09-28 23:35:28 +00:00
parent 0910891995
commit 5fe306d64d
2 changed files with 272 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
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'
const dirs: string[] = []
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-test-'))
dirs.push(dir)
const file = path.join(dir, 'test.db')
return { db: await openGameDb(file), file }
}
afterAll(async () => {
for (const d of dirs) await rm(d, { recursive: true, force: true })
})
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
const [save] = await db.db.select().from(schema.saves).limit(1)
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
}
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
describe('getShipState', () => {
it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile()
const state = await getShipState(db)
expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES
for (const def of SHIP_TYPES) {
const row = systemOf(state, def.type)
if (def.builtAtStart) {
expect(row.built).toBe(true)
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
} else {
expect(row.built).toBe(false) // unbuilt until a BUILD action
expect(row.level).toBe(0)
expect(row.capacity).toBe(0)
}
}
expect(state.inventory).toEqual([])
expect(state.turn).toBe(0)
})
})
describe('upgradeSystem', () => {
it('rejects with 422 and mutates nothing when the hold is empty', async () => {
const { db } = await freshFile()
const before = await getShipState(db)
const reactorId = systemOf(before, 'REACTOR').id
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 })
const after = await getShipState(db)
expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change…
expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action
})
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 })
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
})
it('consumes the cost from the oldest resource row first (deleting rows that hit zero), costs a turn and writes an event', async () => {
const { db } = await freshFile()
await getShipState(db)
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 1
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource
expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1)
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted
expect(state.turn).toBe(1)
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')!
expect(event.turn).toBe(1)
expect(event.message).toContain('REACTOR')
})
it('caps at MAX_LEVEL with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // cost path covered above
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
expect((await getShipState(db)).turn).toBe(0)
})
})
describe('expandSystem', () => {
it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over
const hullId = systemOf(await getShipState(db), 'HULL').id
const state = await expandSystem(db, hullId)
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
})
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 })
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
})
})
describe('buildSystem', () => {
it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => {
const { db } = await freshFile()
await getShipState(db)
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
const state = await buildSystem(db, 'WEAPONS')
const weapons = systemOf(state, 'WEAPONS')
expect(weapons.built).toBe(true)
expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted
expect(state.turn).toBe(1)
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
expect(event.message).toContain('WEAPONS')
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
})
})