import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { encodeSave } from "../src/index"; import { addPilot, addShip, edit, newTestApp, readSave, setCredits, signUp, signUpOwner, TEST_NOW, type TestApp, } from "./support"; const HOUR = 60 * 60_000; describe("fleet sleep in the save (Step 24.1, decisions 0.5.136, 0.5.137)", () => { it("starts a new company with its creation as the last decision and the default threshold", async () => { const { app } = newTestApp(); await signUp(app); const { company } = await readSave(app); expect(company.lastManualDecisionAt).toBe(TEST_NOW); expect(company.sleepAfterHours).toBe(content.config.fleetSleep.defaultHours); }); it("stamps a migrated save's last decision with the time the engine first opens it", async () => { const { app } = newTestApp(); await signUp(app); const current = await readSave(app); // As version 8 wrote it: no sleep fields. const { lastManualDecisionAt: _at, sleepAfterHours: _hours, ...company } = current.company; const v8 = { ...current, saveVersion: 8, company }; const device = newTestApp(); device.clock.now = TEST_NOW + 30 * HOUR; await device.app.call("importSave", { code: await encodeSave(v8 as never) }); const imported = (await readSave(device.app)).company; expect(imported.lastManualDecisionAt).toBe(TEST_NOW + 30 * HOUR); expect(imported.sleepAfterHours).toBe(24); }); }); describe("manual decisions (Step 24.2, decision 0.5.136)", () => { it("stamps the time on a dispatch, not on viewing, dismissing or exporting", async () => { const test = newTestApp(); const owner = await signUpOwner(test.app); const last = async () => (await readSave(test.app)).company.lastManualDecisionAt; const { dispatch } = await test.app.call("createDispatch", { kind: "reposition", shipId: owner.shipId, pilotId: (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id, destinationId: "moon", }); const dispatchedAt = test.clock.now; expect(await last()).toBe(dispatchedAt); // Later: the trip lands; viewing, dismissing its summary and exporting change nothing. test.clock.now = dispatch.arrivalAt + HOUR; await test.app.call("getPlayerState", {}); await test.app.call("getContractBoard", { locationId: "earth" }); await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] }); await test.app.call("markWelcomeSeen", {}); await test.app.call("exportSave", {}); expect(await last()).toBe(dispatchedAt); }); it("doesn't count a refused command", async () => { const test = newTestApp(); await signUpOwner(test.app); const start = (await readSave(test.app)).company.lastManualDecisionAt; test.clock.now += HOUR; await expect(test.app.call("buyDispatchOffice", {})).rejects.toThrow(); expect((await readSave(test.app)).company.lastManualDecisionAt).toBe(start); }); it("never counts what Independent pilots do", async () => { const test = newTestApp(); await signUpOwner(test.app); await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice + 50_000); await test.app.call("buyDispatchOffice", {}); await addShip(test.app, { id: "s1", modelId: "courier", name: "Wren" }); await addPilot(test.app, { id: "p1", name: "Ada", assignedShipId: "s1" }); await test.app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); const decided = (await readSave(test.app)).company.lastManualDecisionAt; test.clock.now += 4 * HOUR; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); expect(save.dispatches.some((d) => d.independent)).toBe(true); expect(save.company.lastManualDecisionAt).toBe(decided); }); }); const DAY = 24 * HOUR; /** An owner with the Dispatch Office and two Independent pilots on Couriers at Earth. */ async function sleepyFleet(test: TestApp) { await signUpOwner(test.app); await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice + 50_000); await test.app.call("buyDispatchOffice", {}); for (const id of ["pa", "pb"]) { await addShip(test.app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` }); await addPilot(test.app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` }); await test.app.call("setPilotMode", { pilotId: id, mode: "independent" }); } return (await readSave(test.app)).company.lastManualDecisionAt!; } const independentTrips = (save: SaveDocument) => save.dispatches.filter((d) => d.independent); describe("fleet sleep in catch-up (Step 24.3, decision 0.5.137)", () => { it("starts no Independent trips after 24 hours without orders; trips in flight still pay", async () => { const test = newTestApp(); const decided = await sleepyFleet(test); const sleep = decided + 24 * HOUR; test.clock.now = decided + 7 * DAY; const state = await test.app.call("getPlayerState", {}); const trips = independentTrips(await readSave(test.app)); expect(trips.length).toBeGreaterThan(10); expect(Math.max(...trips.map((d) => d.record.departureAt))).toBeLessThan(sleep); const flying = trips.filter((d) => d.record.departureAt < sleep && d.arrivalAt > sleep); expect(flying.length).toBeGreaterThan(0); for (const d of flying) { expect(d.status).toBe("resolved"); expect(d.resolution!.credits).toBeGreaterThan(0); } expect(state.fleetSleep).toEqual({ afterHours: 24, sleepsAt: sleep, asleep: true }); }); it("gives the same save caught up in one go or in pieces", async () => { const once = newTestApp(); await sleepyFleet(once); const { code } = await once.app.call("exportSave", {}); const pieces = newTestApp(); pieces.clock.now = once.clock.now; await pieces.app.call("importSave", { code }); const end = once.clock.now + 3 * DAY; once.clock.now = end; await once.app.call("getPlayerState", {}); while (pieces.clock.now < end) { pieces.clock.now = Math.min(end, pieces.clock.now + 5 * HOUR); await pieces.app.call("getPlayerState", {}); } const a = await readSave(once.app); const b = await readSave(pieces.app); expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); }); it("lets a trip the player sent before the sleep time land after it as normal", async () => { const test = newTestApp(); const owner = await signUpOwner(test.app); const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id; const { dispatch } = await test.app.call("createDispatch", { kind: "reposition", shipId: owner.shipId, pilotId: avatar, destinationId: "moon", }); // The fleet goes quiet a minute into the flight. await edit(test.app, (save) => { save.company.lastManualDecisionAt = test.clock.now - 24 * HOUR + 60_000; }); test.clock.now = dispatch.arrivalAt + HOUR; const state = await test.app.call("getPlayerState", {}); expect(state.fleetSleep.asleep).toBe(true); const flown = (await readSave(test.app)).dispatches.find((d) => d.id === dispatch.id)!; expect(flown.status).toBe("resolved"); expect(state.ships.find((s) => s.id === owner.shipId)!.locationId).toBe("moon"); }); it("moves the cutoff with a 48-hour threshold", async () => { const test = newTestApp(); const decided = await sleepyFleet(test); await edit(test.app, (save) => { save.company.sleepAfterHours = 48; }); test.clock.now = decided + 4 * DAY; await test.app.call("getPlayerState", {}); const departures = independentTrips(await readSave(test.app)).map((d) => d.record.departureAt); expect(Math.max(...departures)).toBeGreaterThan(decided + 24 * HOUR); expect(Math.max(...departures)).toBeLessThan(decided + 48 * HOUR); }); }); describe("waking the fleet (Step 24.4, decision 0.5.138)", () => { /** Asleep for two days, then the player decides something at `wake`. */ async function asleep() { const test = newTestApp(); const decided = await sleepyFleet(test); const wake = decided + 3 * DAY; test.clock.now = wake; expect((await test.app.call("getPlayerState", {})).fleetSleep.asleep).toBe(true); return { test, wake }; } const tripsFrom = async (test: TestApp, from: number) => independentTrips(await readSave(test.app)).filter((d) => d.record.departureAt >= from); it('puts Independent pilots back to work with "Back to work"', async () => { const { test, wake } = await asleep(); expect(await tripsFrom(test, wake - DAY)).toEqual([]); const state = await test.app.call("backToWork", {}); expect(state.fleetSleep).toEqual({ afterHours: 24, sleepsAt: wake + DAY, asleep: false }); test.clock.now = wake + HOUR; await test.app.call("getPlayerState", {}); const trips = await tripsFrom(test, wake); expect(trips.length).toBeGreaterThan(0); // From the moment of waking, not before. expect(Math.min(...trips.map((d) => d.record.departureAt))).toBeGreaterThanOrEqual(wake); }); it("wakes them with any other manual decision, such as buying fuel", async () => { const { test, wake } = await asleep(); const ship = (await readSave(test.app)).ships.find( (s) => s.locationId === "earth" && s.fuelFu > 10, )!; await edit(test.app, (save) => { save.ships.find((s) => s.id === ship.id)!.fuelFu -= 10; }); await test.app.call("refuelShip", { shipId: ship.id, amountFu: 5 }); test.clock.now = wake + HOUR; expect((await test.app.call("getPlayerState", {})).fleetSleep.asleep).toBe(false); expect((await tripsFrom(test, wake)).length).toBeGreaterThan(0); }); }); describe("the sleep threshold setting (Step 24.5, decision 0.5.137)", () => { it("takes one of the offered times, counts as a decision, and refuses others", async () => { const test = newTestApp(); await signUpOwner(test.app); test.clock.now += HOUR; const state = await test.app.call("setSleepThreshold", { hours: 72 }); expect(state.fleetSleep).toEqual({ afterHours: 72, sleepsAt: test.clock.now + 72 * HOUR, asleep: false, }); await expect(test.app.call("setSleepThreshold", { hours: 36 })).rejects.toMatchObject({ code: "validation-error", }); expect((await readSave(test.app)).company.sleepAfterHours).toBe(72); }); });