import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { repairCost, repairShopMs, restEndsAt, upgradeCost } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { edit, insertDispatch, newTestApp, readSave, setCredits, signUpOwner } from "./support"; const MINUTE = 60_000; const courier = content.shipModel("courier")!; const { config } = content; const shipOf = (save: SaveDocument, id: string) => save.ships.find((s) => s.id === id)!; const avatarOf = (save: SaveDocument) => save.pilots.find((p) => p.isAvatar)!; /** An owner with a dented Courier at Earth (their HQ) and `credits`. */ async function dented(hull: number, credits = 100_000) { const test = newTestApp({ devTools: true }); const owner = await signUpOwner(test.app); await setCredits(test.app, credits); await edit(test.app, (save) => { shipOf(save, owner.shipId).hullConditionPct = hull; }); return { test, owner }; } describe("booking service (Step 27.3, decision 0.5.153)", () => { it("starts at once when the ship is idle at the HQ: charged, in the shop, logged", async () => { const { test, owner } = await dented(60); const cost = repairCost(courier, 60, config) + upgradeCost(courier, 1, config); const now = test.clock.now; const { ship } = await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: "cargo-bay", }); expect(ship.booking).toMatchObject({ status: "in-shop", hqId: "earth", by: "player" }); // Repair then the upgrade, in one stay. expect(ship.inShopUntil).toBe(now + repairShopMs(60, config) + 10 * MINUTE); const save = await readSave(test.app); expect(save.company.credits).toBe(100_000 - cost); expect(shipOf(save, owner.shipId).upgrades["cargo-bay"]).toBe(1); expect(save.logbook.find((e) => e.kind === "service")).toMatchObject({ phase: "started", cost, }); test.clock.now = ship.inShopUntil!; const state = await test.app.call("getPlayerState", {}); expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({ hullConditionPct: 100, booking: null, }); const kinds = (await readSave(test.app)).logbook.map((e) => e.kind === "service" ? `service-${e.phase}` : e.kind, ); expect(kinds).toEqual(expect.arrayContaining(["repair", "upgrade", "service-finished"])); }); it("starts on arrival at the HQ, charged for the hull it lands with", async () => { const { test, owner } = await dented(60); const avatar = avatarOf(await readSave(test.app)).id; await edit(test.app, (save) => { shipOf(save, owner.shipId).locationId = "moon"; }); const record = await insertDispatch(test.app, { shipId: owner.shipId, pilotId: avatar, departAt: test.clock.now, contract: null, destinationId: "earth", }); const { ship } = await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null, }); expect(ship.booking).toMatchObject({ status: "booked", hqId: "earth" }); expect(ship.inShopUntil).toBeNull(); test.clock.now = record.arrivalAt + MINUTE; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const landed = save.logbook.find((e) => e.kind === "service" && e.phase === "started")!; const hull = shipOf(save, owner.shipId).hullConditionPct; expect(landed.at).toBe(record.arrivalAt); expect(save.company.credits).toBe(100_000 - repairCost(courier, hull, config)); expect(shipOf(save, owner.shipId).inShopUntil).toBe( record.arrivalAt + repairShopMs(hull, config), ); }); it("runs a rest due on arrival alongside the service: free at the later end, duty reset", async () => { const { test, owner } = await dented(60); const avatar = avatarOf(await readSave(test.app)).id; await edit(test.app, (save) => { shipOf(save, owner.shipId).locationId = "moon"; // Near the duty limit: the trip home ends in mandatory rest. save.pilots.find((p) => p.id === avatar)!.dutyHours = config.dutyLimitGameHours - 0.5; }); const record = await insertDispatch(test.app, { shipId: owner.shipId, pilotId: avatar, departAt: test.clock.now, contract: null, destinationId: "earth", }); await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null }); test.clock.now = record.arrivalAt + MINUTE; await test.app.call("getPlayerState", {}); let save = await readSave(test.app); const ship = shipOf(save, owner.shipId); expect(avatarOf(save).restEndsAt).toBe(restEndsAt(record.arrivalAt, config)); // The repair (20 min) outlasts the rest (10 min): the pilot stays with the ship. expect(ship.shopPilotId).toBe(avatar); test.clock.now = ship.inShopUntil! + MINUTE; await test.app.call("getPlayerState", {}); save = await readSave(test.app); expect(avatarOf(save).dutyHours).toBe(0); expect(avatarOf(save).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!); }); it("overlaps a rest already running when the work starts", async () => { const { test, owner } = await dented(60); const now = test.clock.now; const avatar = avatarOf(await readSave(test.app)).id; await edit(test.app, (save) => { Object.assign( save.pilots.find((p) => p.id === avatar)!, { dutyHours: 0, restEndsAt: now + 5 * MINUTE, }, ); }); const { ship } = await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null, }); expect(ship.booking!.status).toBe("in-shop"); expect(shipOf(await readSave(test.app), owner.shipId).shopPilotId).toBe(avatar); test.clock.now = ship.inShopUntil!; await test.app.call("getPlayerState", {}); // Free when the later (the shop) ends: not 5 minutes of rest plus 20 of shop. expect(avatarOf(await readSave(test.app)).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!); }); it("waits for funds on a player booking, then starts when the credits cover it", async () => { const cost = repairCost(courier, 60, config); const { test, owner } = await dented(60, cost - 1); const { ship } = await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null, }); expect(ship.booking).toMatchObject({ status: "waiting-funds", estimate: cost }); expect(ship.inShopUntil).toBeNull(); // Logged once, however often it's checked. await test.app.call("getPlayerState", {}); let save = await readSave(test.app); expect(save.logbook.filter((e) => e.kind === "service")).toHaveLength(1); await setCredits(test.app, cost); await test.app.call("getPlayerState", {}); save = await readSave(test.app); expect(save.company.credits).toBe(0); expect(shipOf(save, owner.shipId).booking!.startedAt).toBe(test.clock.now); }); it("refuses what can't be booked", async () => { const { test, owner } = await dented(100); const book = (input: { repair: boolean; upgradeId: string | null; hqId?: string }) => test.app.call("bookService", { shipId: owner.shipId, ...input }); await expect(book({ repair: false, upgradeId: null })).rejects.toMatchObject({ code: "nothing-to-service", }); await expect(book({ repair: true, upgradeId: null })).rejects.toMatchObject({ code: "hull-intact", }); await expect( book({ repair: false, upgradeId: "cargo-bay", hqId: "mars" }), ).rejects.toMatchObject({ code: "unknown-hq" }); }); }); describe("canceling a booking (Step 27.3, decision 0.5.153)", () => { it("releases a waiting booking, and can't cancel work that has started", async () => { const { test, owner } = await dented(60, 0); await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null }); const { ship } = await test.app.call("cancelBooking", { shipId: owner.shipId }); expect(ship.booking).toBeNull(); await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({ code: "no-booking", }); await setCredits(test.app, 100_000); await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null }); await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({ code: "service-started", }); }); }); describe("auto-repair (Step 27.3, decision 0.5.157)", () => { async function independent(credits: number) { const { test, owner } = await dented(45, credits); await edit(test.app, (save) => { // Independent, but with no decision scheduled: the ship stays put. Object.assign(avatarOf(save), { mode: "independent", nextDecisionAt: null }); }); return { test, owner }; } it("books a repair for an Independent crew when the hull drops below the threshold", async () => { const { test, owner } = await independent(1_000_000); await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" }); const save = await readSave(test.app); // Idle at the HQ already: the automatic repair starts. expect(shipOf(save, owner.shipId).booking).toMatchObject({ by: "auto", hqId: "earth" }); expect( save.logbook.filter((e) => e.kind === "service").map((e) => e.kind === "service" && e.phase), ).toEqual(["booked", "started"]); }); it("waits while an automatic repair would take credits below the spending floor", async () => { const floor = config.independentPilots.defaultSpendingFloor; const { test, owner } = await independent(floor); await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" }); const state = await test.app.call("getPlayerState", {}); expect(state.ships.find((s) => s.id === owner.shipId)!.booking).toMatchObject({ by: "auto", status: "waiting-funds", }); await setCredits(test.app, floor + repairCost(courier, 20, config)); await test.app.call("getPlayerState", {}); expect((await readSave(test.app)).company.credits).toBe(floor); }); it("only recommends a repair for a Manual crew, and honors the setting", async () => { const { test, owner } = await dented(45); await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" }); let ship = (await test.app.call("getPlayerState", {})).ships.find( (s) => s.id === owner.shipId, )!; expect(ship).toMatchObject({ booking: null, repairRecommended: true }); ({ ship } = await test.app.call("setAutoRepair", { shipId: owner.shipId, on: false, thresholdPct: 55, })); expect(ship).toMatchObject({ autoRepair: { on: false, thresholdPct: 55 }, repairRecommended: false, }); for (const thresholdPct of [42, 5, 95]) { await expect( test.app.call("setAutoRepair", { shipId: owner.shipId, on: true, thresholdPct }), ).rejects.toMatchObject({ code: "validation-error" }); } }); });