deadhead-space-freight-tycoon/packages/engine/test/catch-up.test.ts

172 lines
7.2 KiB
TypeScript

import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { MS_PER_MINUTE } from "@deadhead/sim";
import { describe, expect, it } 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";
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<TestApp> {
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);
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);
});
});