deadhead-space-freight-tycoon/packages/engine/test/service.test.ts

263 lines
11 KiB
TypeScript

import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { repairCost, repairShopMs, restEndsAt, upgradeCost } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { edit, insertDispatch, newTestApp, readSave, setCredits, signUpOwner } from "./support";
const MINUTE = 60_000;
const courier = content.shipModel("courier")!;
const { config } = content;
const shipOf = (save: SaveDocument, id: string) => save.ships.find((s) => s.id === id)!;
const avatarOf = (save: SaveDocument) => save.pilots.find((p) => p.isAvatar)!;
/** An owner with a dented Courier at Earth (their HQ) and `credits`. */
async function dented(hull: number, credits = 100_000) {
const test = newTestApp({ devTools: true });
const owner = await signUpOwner(test.app);
await setCredits(test.app, credits);
await edit(test.app, (save) => {
shipOf(save, owner.shipId).hullConditionPct = hull;
});
return { test, owner };
}
describe("booking service (Step 27.3, decision 0.5.153)", () => {
it("starts at once when the ship is idle at the HQ: charged, in the shop, logged", async () => {
const { test, owner } = await dented(60);
const cost = repairCost(courier, 60, config) + upgradeCost(courier, 1, config);
const now = test.clock.now;
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: "cargo-bay",
});
expect(ship.booking).toMatchObject({ status: "in-shop", hqId: "earth", by: "player" });
// Repair then the upgrade, in one stay.
expect(ship.inShopUntil).toBe(now + repairShopMs(60, config) + 10 * MINUTE);
const save = await readSave(test.app);
expect(save.company.credits).toBe(100_000 - cost);
expect(shipOf(save, owner.shipId).upgrades["cargo-bay"]).toBe(1);
expect(save.logbook.find((e) => e.kind === "service")).toMatchObject({
phase: "started",
cost,
});
test.clock.now = ship.inShopUntil!;
const state = await test.app.call("getPlayerState", {});
expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({
hullConditionPct: 100,
booking: null,
});
const kinds = (await readSave(test.app)).logbook.map((e) =>
e.kind === "service" ? `service-${e.phase}` : e.kind,
);
expect(kinds).toEqual(expect.arrayContaining(["repair", "upgrade", "service-finished"]));
});
it("starts on arrival at the HQ, charged for the hull it lands with", async () => {
const { test, owner } = await dented(60);
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
shipOf(save, owner.shipId).locationId = "moon";
});
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: null,
destinationId: "earth",
});
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking).toMatchObject({ status: "booked", hqId: "earth" });
expect(ship.inShopUntil).toBeNull();
test.clock.now = record.arrivalAt + MINUTE;
await test.app.call("getPlayerState", {});
const save = await readSave(test.app);
const landed = save.logbook.find((e) => e.kind === "service" && e.phase === "started")!;
const hull = shipOf(save, owner.shipId).hullConditionPct;
expect(landed.at).toBe(record.arrivalAt);
expect(save.company.credits).toBe(100_000 - repairCost(courier, hull, config));
expect(shipOf(save, owner.shipId).inShopUntil).toBe(
record.arrivalAt + repairShopMs(hull, config),
);
});
it("runs a rest due on arrival alongside the service: free at the later end, duty reset", async () => {
const { test, owner } = await dented(60);
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
shipOf(save, owner.shipId).locationId = "moon";
// Near the duty limit: the trip home ends in mandatory rest.
save.pilots.find((p) => p.id === avatar)!.dutyHours = config.dutyLimitGameHours - 0.5;
});
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: null,
destinationId: "earth",
});
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
test.clock.now = record.arrivalAt + MINUTE;
await test.app.call("getPlayerState", {});
let save = await readSave(test.app);
const ship = shipOf(save, owner.shipId);
expect(avatarOf(save).restEndsAt).toBe(restEndsAt(record.arrivalAt, config));
// The repair (20 min) outlasts the rest (10 min): the pilot stays with the ship.
expect(ship.shopPilotId).toBe(avatar);
test.clock.now = ship.inShopUntil! + MINUTE;
await test.app.call("getPlayerState", {});
save = await readSave(test.app);
expect(avatarOf(save).dutyHours).toBe(0);
expect(avatarOf(save).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!);
});
it("overlaps a rest already running when the work starts", async () => {
const { test, owner } = await dented(60);
const now = test.clock.now;
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
Object.assign(
save.pilots.find((p) => p.id === avatar)!,
{
dutyHours: 0,
restEndsAt: now + 5 * MINUTE,
},
);
});
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking!.status).toBe("in-shop");
expect(shipOf(await readSave(test.app), owner.shipId).shopPilotId).toBe(avatar);
test.clock.now = ship.inShopUntil!;
await test.app.call("getPlayerState", {});
// Free when the later (the shop) ends: not 5 minutes of rest plus 20 of shop.
expect(avatarOf(await readSave(test.app)).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!);
});
it("waits for funds on a player booking, then starts when the credits cover it", async () => {
const cost = repairCost(courier, 60, config);
const { test, owner } = await dented(60, cost - 1);
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking).toMatchObject({ status: "waiting-funds", estimate: cost });
expect(ship.inShopUntil).toBeNull();
// Logged once, however often it's checked.
await test.app.call("getPlayerState", {});
let save = await readSave(test.app);
expect(save.logbook.filter((e) => e.kind === "service")).toHaveLength(1);
await setCredits(test.app, cost);
await test.app.call("getPlayerState", {});
save = await readSave(test.app);
expect(save.company.credits).toBe(0);
expect(shipOf(save, owner.shipId).booking!.startedAt).toBe(test.clock.now);
});
it("refuses what can't be booked", async () => {
const { test, owner } = await dented(100);
const book = (input: { repair: boolean; upgradeId: string | null; hqId?: string }) =>
test.app.call("bookService", { shipId: owner.shipId, ...input });
await expect(book({ repair: false, upgradeId: null })).rejects.toMatchObject({
code: "nothing-to-service",
});
await expect(book({ repair: true, upgradeId: null })).rejects.toMatchObject({
code: "hull-intact",
});
await expect(
book({ repair: false, upgradeId: "cargo-bay", hqId: "mars" }),
).rejects.toMatchObject({ code: "unknown-hq" });
});
});
describe("canceling a booking (Step 27.3, decision 0.5.153)", () => {
it("releases a waiting booking, and can't cancel work that has started", async () => {
const { test, owner } = await dented(60, 0);
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
const { ship } = await test.app.call("cancelBooking", { shipId: owner.shipId });
expect(ship.booking).toBeNull();
await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({
code: "no-booking",
});
await setCredits(test.app, 100_000);
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({
code: "service-started",
});
});
});
describe("auto-repair (Step 27.3, decision 0.5.157)", () => {
async function independent(credits: number) {
const { test, owner } = await dented(45, credits);
await edit(test.app, (save) => {
// Independent, but with no decision scheduled: the ship stays put.
Object.assign(avatarOf(save), { mode: "independent", nextDecisionAt: null });
});
return { test, owner };
}
it("books a repair for an Independent crew when the hull drops below the threshold", async () => {
const { test, owner } = await independent(1_000_000);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
const save = await readSave(test.app);
// Idle at the HQ already: the automatic repair starts.
expect(shipOf(save, owner.shipId).booking).toMatchObject({ by: "auto", hqId: "earth" });
expect(
save.logbook.filter((e) => e.kind === "service").map((e) => e.kind === "service" && e.phase),
).toEqual(["booked", "started"]);
});
it("waits while an automatic repair would take credits below the spending floor", async () => {
const floor = config.independentPilots.defaultSpendingFloor;
const { test, owner } = await independent(floor);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
const state = await test.app.call("getPlayerState", {});
expect(state.ships.find((s) => s.id === owner.shipId)!.booking).toMatchObject({
by: "auto",
status: "waiting-funds",
});
await setCredits(test.app, floor + repairCost(courier, 20, config));
await test.app.call("getPlayerState", {});
expect((await readSave(test.app)).company.credits).toBe(floor);
});
it("only recommends a repair for a Manual crew, and honors the setting", async () => {
const { test, owner } = await dented(45);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
let ship = (await test.app.call("getPlayerState", {})).ships.find(
(s) => s.id === owner.shipId,
)!;
expect(ship).toMatchObject({ booking: null, repairRecommended: true });
({ ship } = await test.app.call("setAutoRepair", {
shipId: owner.shipId,
on: false,
thresholdPct: 55,
}));
expect(ship).toMatchObject({
autoRepair: { on: false, thresholdPct: 55 },
repairRecommended: false,
});
for (const thresholdPct of [42, 5, 95]) {
await expect(
test.app.call("setAutoRepair", { shipId: owner.shipId, on: true, thresholdPct }),
).rejects.toMatchObject({ code: "validation-error" });
}
});
});