import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { MS_PER_MINUTE } from "@deadhead/sim"; import { describe, expect, it, vi } from "vitest"; import { turnaroundMs } from "../src/game/independent"; import { upcomingEvents } from "../src/index"; import { addPilot, addShip, edit, newTestApp, putBoard, readSave, setCredits, signUpOwner, testContract, TEST_NOW, type TestApp, } from "./support"; // Long catch-ups and simulations: slow under coverage on CI. vi.setConfig({ testTimeout: 60_000 }); const HOUR = 60 * MS_PER_MINUTE; const DAY = 24 * HOUR; const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE; /** An owner with the Dispatch Office and Independent pilots, each crewing a Courier at Earth. */ async function independentFleet(test: TestApp, pilotIds: string[] = ["pa"]) { const { app } = test; await signUpOwner(app); await setCredits(app, content.config.independentPilots.dispatchOfficePrice + 50_000); await app.call("buyDispatchOffice", {}); for (const id of pilotIds) { await addShip(app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` }); await addPilot(app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` }); await app.call("setPilotMode", { pilotId: id, mode: "independent" }); } } const independentTrips = (save: SaveDocument) => save.dispatches.filter((d) => d.independent); /** A second engine holding a copy of `test`'s save, at the same time. */ async function copyOf(test: TestApp): Promise { const { code } = await test.app.call("exportSave", {}); const copy = newTestApp(); copy.clock.now = test.clock.now; await copy.app.call("importSave", { code }); return copy; } describe("event-queue catch-up (Step 21.4, decision 0.5.129)", () => { it("gives the same save whether 8 hours are caught up at once or an hour at a time", async () => { const once = newTestApp(); await independentFleet(once, ["pa", "pb"]); const pieces = await copyOf(once); once.clock.now += 8 * HOUR; await once.app.call("getPlayerState", {}); for (let hour = 0; hour < 8; hour++) { pieces.clock.now += HOUR; await pieces.app.call("getPlayerState", {}); } const a = await readSave(once.app); const b = await readSave(pieces.app); expect(independentTrips(a).length).toBeGreaterThan(20); expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); }); it("never lets two pilots take the same contract: the earlier decision, then the lower id, wins", async () => { const test = newTestApp(); // Just past the first look, so both pilots decide on the stored board. test.clock.now = TEST_NOW + FIRST_LOOK; await independentFleet(test, ["pb", "pa"]); const best = testContract({ id: "earth:best", payout: 5_000 }); const next = testContract({ id: "earth:next", payout: 3_000 }); await putBoard(test.app, [best, next], test.clock.now); test.clock.now += turnaroundMs(); await test.app.call("getPlayerState", {}); const trips = independentTrips(await readSave(test.app)); const taken = Object.fromEntries(trips.map((d) => [d.pilotId, d.record.contract?.id])); expect(taken).toEqual({ pa: "earth:best", pb: "earth:next" }); }); it("rests a pilot over the duty limit on arrival, and resumes after the rest", async () => { const test = newTestApp(); test.clock.now = TEST_NOW + FIRST_LOOK; await independentFleet(test); // One trip from the limit. await edit(test.app, (save) => { save.pilots.find((p) => p.id === "pa")!.dutyHours = content.config.dutyLimitGameHours - 0.1; }); test.clock.now += 2 * HOUR; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const [first, second] = independentTrips(save).sort( (x, y) => x.record.departureAt - y.record.departureAt, ); expect(first!.resolution!.pilotRestEndsAt).not.toBeNull(); const restEnds = first!.resolution!.pilotRestEndsAt!; // Nothing while resting; the next job after the rest and the turnaround. expect(second).toBeDefined(); expect(second!.record.departureAt).toBeGreaterThanOrEqual(restEnds + turnaroundMs()); }); it("stops pilots deciding 7 days after the last saved time", async () => { const test = newTestApp(); await independentFleet(test); // Longer than the 10 days below, so only the cap stops them (not fleet sleep, CR-024). await edit(test.app, (save) => { save.company.sleepAfterHours = 30 * 24; }); const start = (await readSave(test.app)).lastTimeSeen; test.clock.now = start + 10 * DAY; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const departures = independentTrips(save).map((d) => d.record.departureAt); const cap = start + content.config.independentPilots.maxCatchUpRealDays * DAY; expect(Math.max(...departures)).toBeLessThanOrEqual(cap); // They worked right up to the cap, and pick up again from now. expect(Math.max(...departures)).toBeGreaterThan(cap - DAY); expect(save.pilots.find((p) => p.id === "pa")!.nextDecisionAt).toBeGreaterThanOrEqual( start + 10 * DAY, ); }); // Every pilot dispatch goes through dispatchContract, which re-runs validateDispatch. it("dispatches pilots only in ways that respect the player's priority", async () => { const test = newTestApp(); await independentFleet(test, ["pa", "pb"]); test.clock.now += 4 * HOUR; await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const trips = independentTrips(save).filter((d) => d.record.contract); expect(trips.length).toBeGreaterThan(10); for (const d of trips) { const contract = d.record.contract!; expect(contract.firstContact).toBe(false); // Past the first-look window of the contract's board period. expect(d.record.departureAt - contract.offeredAt).toBeGreaterThanOrEqual(FIRST_LOOK); expect(d.record.departureAt).toBeLessThan(contract.expiresAt); // Each one is a distinct contract. expect(trips.filter((o) => o.record.contract!.id === contract.id)).toHaveLength(1); } }); it("catches up in chunks, reporting progress, with the same result as one go", async () => { const once = newTestApp(); await independentFleet(once, ["pa", "pb"]); const chunked = await copyOf(once); once.clock.now += 3 * HOUR; chunked.clock.now += 3 * HOUR; await once.app.resolveDue(null); const progress: number[] = []; await chunked.app.resolveDue(null, { chunkEvents: 5, onProgress: (p) => progress.push(p) }); expect(progress.length).toBeGreaterThan(3); expect(progress).toEqual([...progress].sort((x, y) => x - y)); expect(progress.at(-1)).toBe(1); const a = await readSave(once.app); const b = await readSave(chunked.app); expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); }); it("schedules no notification for a pilot's own trips (decision 0.5.130)", async () => { const test = newTestApp(); test.clock.now = TEST_NOW + FIRST_LOOK; await independentFleet(test); test.clock.now += turnaroundMs(); await test.app.call("getPlayerState", {}); const save = await readSave(test.app); const flying = independentTrips(save).filter((d) => d.status === "in-flight"); expect(flying).toHaveLength(1); const events = upcomingEvents(save, test.clock.now); expect(events.some((e) => e.at === flying[0]!.arrivalAt)).toBe(false); }); });