import { content } from "@deadhead/content"; import { dispatchResponseSchema, errorResponseSchema, shipResponseSchema, type PlayerState, } from "@deadhead/schema"; import { effectiveStats, MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours, upgradeCost, } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { setCredits, useTestDatabase } from "./db"; import { call, getState, insertDispatch, putBoard, signUpOwner, testContract, useTestApp, type Guest, } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); const errorCode = (response: { json(): unknown }) => errorResponseSchema.parse(response.json()).error.code; const freighterModel = content.shipModel("freighter")!; const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z"); /** An owner whose avatar now crews a newly bought Freighter at the Earth HQ. */ async function ownerWithFreighter() { const { app } = testApp(); const owner = await signUpOwner(app, database().db); await setCredits(database().db, owner.playerId, freighterModel.price + 50_000); const bought = await call(app, owner, "POST", "/api/ships", { modelId: "freighter", bayId: owner.earthBayId, }); const freighter = shipResponseSchema.parse(bought.json()).ship; await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {}); await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/assign`, { shipId: freighter.id, }); return { owner, freighter }; } const upgrade = (owner: Guest, shipId: string, upgradeId: string) => call(testApp().app, owner, "POST", `/api/ships/${shipId}/upgrades`, { upgradeId }); const shipIn = (state: PlayerState, id: string) => state.ships.find((s) => s.id === id)!; describe("starting an upgrade (Step 10I.5)", () => { it("charges, raises the level, keeps the ship in the shop, then flies it faster", async () => { const { app, clock } = testApp(); const { owner, freighter } = await ownerWithFreighter(); const before = (await getState(app, owner)).company.credits; const response = await upgrade(owner, freighter.id, "engines"); expect(response.statusCode).toBe(200); const until = clock.now + 10 * 60_000; expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({ upgrades: { engines: 1 }, inShopUntil: until, shop: { upgradeId: "engines", level: 1, until }, }); const state = await getState(app, owner); expect(state.company.credits).toBe(before - upgradeCost(freighterModel, 1, content.config)); expect(shipIn(state, freighter.id).stats.cruiseSpeedKmPerGameHour).toBeCloseTo( freighterModel.cruiseSpeedKmPerGameHour * 1.1, ); const job = testContract({ massTonnes: 60, expiresAt: FAR_FUTURE }); await putBoard(database().db, owner.playerId, [job], clock.now); const dispatch = () => call(app, owner, "POST", "/api/dispatches", { kind: "contract", contractId: job.id, shipId: freighter.id, pilotId: owner.avatarId, }); const refused = await dispatch(); expect(refused.statusCode).toBe(409); expect(errorCode(refused)).toBe("ship-in-shop"); clock.now = until; const flown = await dispatch(); expect(flown.statusCode).toBe(200); const { dispatch: trip } = dispatchResponseSchema.parse(flown.json()); const distance = routeDistance( content.location("earth")!, content.location("moon")!, realToGame(until, content.config), ); const hours = travelTimeGameHours( effectiveStats(freighterModel, { engines: 1 }, content), distance, job.massTonnes, content.config, ); expect(trip.arrivalAt - trip.departureAt).toBeCloseTo( (hours * MS_PER_HOUR) / content.config.timeCompression, -1, ); }); it("refuses away from an HQ with not-at-hq", async () => { const { app, clock } = testApp(); const owner = await signUpOwner(app, database().db); await setCredits(database().db, owner.playerId, 50_000); // Fly the Courier to the Moon, where the player has no bay. const record = await insertDispatch(database().db, { playerId: owner.playerId, shipId: owner.shipId, pilotId: owner.avatarId, departAt: clock.now, contract: testContract(), }); clock.now = record.arrivalAt; const credits = (await getState(app, owner)).company.credits; const response = await upgrade(owner, owner.shipId, "cargo-bay"); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("not-at-hq"); expect((await getState(app, owner)).company.credits).toBe(credits); }); it("refuses an unknown upgrade with not-found", async () => { const owner = await signUpOwner(testApp().app, database().db); expect((await upgrade(owner, owner.shipId, "warp-drive")).statusCode).toBe(404); }); it("makes exactly one of two simultaneous upgrades the player can afford only one of", async () => { const { app } = testApp(); const owner = await signUpOwner(app, database().db); const courier = content.shipModel("courier")!; await setCredits(database().db, owner.playerId, courier.price); const second = shipResponseSchema.parse( ( await call(app, owner, "POST", "/api/ships", { modelId: "courier", bayId: owner.earthBayId, }) ).json(), ).ship; const cost = upgradeCost(courier, 1, content.config); await setCredits(database().db, owner.playerId, cost + cost / 2); const responses = await Promise.all([ upgrade(owner, owner.shipId, "engines"), upgrade(owner, second.id, "engines"), ]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); const state = await getState(app, owner); expect(state.company.credits).toBe(cost / 2); expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1); }); it("keeps the finished job until acknowledged, and only clears finished ones", async () => { const { app, clock } = testApp(); const { owner, freighter } = await ownerWithFreighter(); await upgrade(owner, freighter.id, "cargo-bay"); const ack = () => call(app, owner, "POST", "/api/summaries/acknowledge", { dispatchIds: [], shipIds: [freighter.id], }); // Still in the shop: acknowledging does nothing. expect((await ack()).json()).toEqual({ acknowledged: 0 }); const until = shipIn(await getState(app, owner), freighter.id).shop!.until; clock.now = until; expect(shipIn(await getState(app, owner), freighter.id)).toMatchObject({ inShopUntil: null, shop: { upgradeId: "cargo-bay", level: 1, until }, }); expect((await ack()).json()).toEqual({ acknowledged: 1 }); expect(shipIn(await getState(app, owner), freighter.id).shop).toBeNull(); }); });