263 lines
11 KiB
TypeScript
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" });
|
|
}
|
|
});
|
|
});
|