172 lines
7.2 KiB
TypeScript
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 20.4, decision 0.5.125)", () => {
|
|
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.126)", 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);
|
|
});
|
|
});
|