import { content } from "@deadhead/content"; import { MS_PER_MINUTE, upgradeShopMs } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { turnaroundMs } from "../src/game/independent"; import { addPilot, addShip, edit, newTestApp, readSave, setCredits, signUpOwner, type TestApp, } from "./support"; const SHOP_MS = upgradeShopMs(1, content.config); const LIMIT = content.config.dutyLimitGameHours; const pilotIn = async (test: TestApp, id: string) => (await test.app.call("getPlayerState", {})).pilots.find((p) => p.id === id)!; /** An owner with a Courier at Earth crewed by `p1`, one trip short of the duty limit. */ async function tiredPilot(test: TestApp) { await signUpOwner(test.app); await setCredits(test.app, 100_000); await addShip(test.app, { id: "f1", modelId: "courier", name: "Wren" }); await addPilot(test.app, { id: "p1", name: "Ada", assignedShipId: "f1", dutyHours: LIMIT - 0.5 }); } const upgrade = (test: TestApp) => test.app.call("startUpgrade", { shipId: "f1", upgradeId: "engines" }); describe("shop time counts as rest (Step 23.2, decision 0.5.135)", () => { it("brings a pilot near the duty limit out of the shop with no duty, ready to fly", async () => { const test = newTestApp(); await tiredPilot(test); const startedAt = test.clock.now; await upgrade(test); expect((await pilotIn(test, "p1")).restingInShopUntil).toBe(startedAt + SHOP_MS); test.clock.now = startedAt + SHOP_MS; const pilot = await pilotIn(test, "p1"); expect(pilot.dutyHours).toBe(0); expect(pilot.resting).toBe(false); expect(pilot.restingInShopUntil).toBeNull(); const { dispatch } = await test.app.call("createDispatch", { kind: "reposition", shipId: "f1", pilotId: "p1", destinationId: "moon", }); expect(dispatch.status).toBe("in-flight"); }); it("keeps the duty of a pilot who left the ship and flew another one", async () => { const test = newTestApp(); await tiredPilot(test); await addShip(test.app, { id: "f2", modelId: "courier", name: "Kite" }); // Fresh enough that the Moon trip adds duty without forcing a rest. await edit(test.app, (save) => { save.pilots.find((p) => p.id === "p1")!.dutyHours = 2; }); const startedAt = test.clock.now; await upgrade(test); await test.app.call("unassignPilot", { pilotId: "p1" }); expect((await pilotIn(test, "p1")).restingInShopUntil).toBeNull(); await test.app.call("assignPilot", { pilotId: "p1", shipId: "f2" }); await test.app.call("createDispatch", { kind: "reposition", shipId: "f2", pilotId: "p1", destinationId: "moon", }); test.clock.now = startedAt + SHOP_MS + 60 * MS_PER_MINUTE; const pilot = await pilotIn(test, "p1"); // At the Moon with the trip added to their duty, not reset by the shop. expect(pilot.status).toBe("idle"); expect(pilot.dutyHours).toBeGreaterThan(2); expect((await readSave(test.app)).ships.find((s) => s.id === "f1")!.shopPilotId).toBeNull(); }); it("doesn't reset a pilot who left the ship and came back during the upgrade", async () => { const test = newTestApp(); await tiredPilot(test); const startedAt = test.clock.now; await upgrade(test); await test.app.call("unassignPilot", { pilotId: "p1" }); await test.app.call("assignPilot", { pilotId: "p1", shipId: "f1" }); test.clock.now = startedAt + SHOP_MS; expect((await pilotIn(test, "p1")).dutyHours).toBe(LIMIT - 0.5); }); it("frees a pilot whose rest would have ended after the upgrade, and leaves a shorter rest alone", async () => { const test = newTestApp(); await tiredPilot(test); const startedAt = test.clock.now; await addShip(test.app, { id: "f2", modelId: "courier", name: "Kite" }); await addPilot(test.app, { id: "p2", name: "Bo", assignedShipId: "f2" }); await edit(test.app, (save) => { save.pilots.find((p) => p.id === "p1")!.restEndsAt = startedAt + 3 * SHOP_MS; save.pilots.find((p) => p.id === "p2")!.restEndsAt = startedAt + SHOP_MS / 2; }); await upgrade(test); await test.app.call("startUpgrade", { shipId: "f2", upgradeId: "engines" }); // The short rest runs its course alongside the upgrade. test.clock.now = startedAt + SHOP_MS / 2; expect((await pilotIn(test, "p2")).resting).toBe(false); expect((await pilotIn(test, "p1")).resting).toBe(true); test.clock.now = startedAt + SHOP_MS; const p1 = await pilotIn(test, "p1"); expect(p1.resting).toBe(false); expect(p1.dutyHours).toBe(0); }); it("brings an Independent pilot out rested, flying after the turnaround", async () => { const test = newTestApp(); await tiredPilot(test); await test.app.call("buyDispatchOffice", {}); await edit(test.app, (save) => { save.pilots.find((p) => p.id === "p1")!.restEndsAt = test.clock.now + 3 * SHOP_MS; }); const startedAt = test.clock.now; await upgrade(test); await test.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); test.clock.now = startedAt + SHOP_MS; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const p1 = save.pilots.find((p) => p.id === "p1")!; expect(p1.dutyHours).toBe(0); expect(p1.restEndsAt).toBe(startedAt + SHOP_MS); expect(p1.nextDecisionAt).toBe(startedAt + SHOP_MS + turnaroundMs()); }); it("gives the same save caught up at once as in live play", async () => { const once = newTestApp(); await tiredPilot(once); await once.app.call("buyDispatchOffice", {}); await upgrade(once); await once.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); const { code } = await once.app.call("exportSave", {}); const live = newTestApp(); live.clock.now = once.clock.now; await live.app.call("importSave", { code }); const end = once.clock.now + 3 * 60 * MS_PER_MINUTE; once.clock.now = end; await once.app.call("getPlayerState", {}); while (live.clock.now < end) { live.clock.now = Math.min(end, live.clock.now + 30_000); await live.app.call("getPlayerState", {}); } const a = await readSave(once.app); const b = await readSave(live.app); expect(a.dispatches.some((d) => d.independent)).toBe(true); expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); }); });