260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { SaveDocument } from "@deadhead/schema";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { encodeSave } from "../src/index";
|
|
import {
|
|
addPilot,
|
|
addShip,
|
|
edit,
|
|
newTestApp,
|
|
readSave,
|
|
setCredits,
|
|
signUp,
|
|
signUpOwner,
|
|
TEST_NOW,
|
|
type TestApp,
|
|
} from "./support";
|
|
|
|
// Long catch-ups and simulations: slow under coverage on CI.
|
|
vi.setConfig({ testTimeout: 60_000 });
|
|
|
|
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);
|
|
// A fixed world, so a trip is in the air when the fleet goes quiet (with random worlds
|
|
// and events, none sometimes is).
|
|
await edit(test.app, (save) => {
|
|
save.player.worldSeed = 12_345;
|
|
});
|
|
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);
|
|
// They land and resolve as normal: a contract pays its XP (and its pay, unless an
|
|
// event took it, CR-026), a deadhead home earns nothing.
|
|
for (const d of flying) {
|
|
expect(d.status).toBe("resolved");
|
|
if (d.record.contract) expect(d.resolution!.pilotXp).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);
|
|
});
|
|
});
|