325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { LogEntry, SaveDocument } from "@deadhead/schema";
|
|
import { flightNet, pilotLevel, skillPointsAvailable } from "@deadhead/sim";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
addPilot,
|
|
addShip,
|
|
edit,
|
|
newTestApp,
|
|
putBoard,
|
|
readSave,
|
|
setCredits,
|
|
signUpOwner,
|
|
testContract,
|
|
type TestApp,
|
|
} from "./support";
|
|
|
|
const HOUR = 60 * 60_000;
|
|
const DAY = 24 * HOUR;
|
|
const of = <K extends LogEntry["kind"]>(save: SaveDocument, kind: K) =>
|
|
save.logbook.filter((e): e is Extract<LogEntry, { kind: K }> => e.kind === kind);
|
|
|
|
/** An owner with the avatar's Courier at Earth; returns ids. */
|
|
async function owner(test: TestApp) {
|
|
const o = await signUpOwner(test.app);
|
|
await setCredits(test.app, 100_000);
|
|
return o;
|
|
}
|
|
|
|
/** Flies the avatar on a board contract and lets it land. */
|
|
async function deliver(test: TestApp, avatarId: string, shipId: string, over = {}) {
|
|
const contract = testContract({
|
|
offeredAt: test.clock.now,
|
|
expiresAt: test.clock.now + HOUR,
|
|
...over,
|
|
});
|
|
await putBoard(test.app, [contract], test.clock.now);
|
|
const { dispatch } = await test.app.call("createDispatch", {
|
|
kind: "contract",
|
|
contractId: contract.id,
|
|
shipId,
|
|
pilotId: avatarId,
|
|
});
|
|
test.clock.now = dispatch.arrivalAt + 1_000;
|
|
await test.app.call("getPlayerState", {});
|
|
return { contract, dispatch, save: await readSave(test.app) };
|
|
}
|
|
|
|
describe("logbook entries (Step 25.1, decision 0.5.141)", () => {
|
|
it("logs a delivery with its pilot, ship, route, payout, pilot pay, fuel, loan and XP", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
const { contract, dispatch, save } = await deliver(test, o.avatarId, o.shipId);
|
|
const [flight] = of(save, "flight");
|
|
expect(flight).toMatchObject({
|
|
id: `${dispatch.id}:flight`,
|
|
at: dispatch.arrivalAt,
|
|
pilotId: o.avatarId,
|
|
shipId: o.shipId,
|
|
dispatchId: dispatch.id,
|
|
deadhead: false,
|
|
independent: false,
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
goodId: contract.goodId,
|
|
massTonnes: contract.massTonnes,
|
|
shipper: contract.shipper,
|
|
payout: contract.payout,
|
|
pilotPay: 0,
|
|
fuelCost: dispatch.fuelCost,
|
|
loanRepayment: 0,
|
|
});
|
|
expect(flight!.xp).toBeGreaterThan(0);
|
|
expect(of(save, "flight")).toHaveLength(1);
|
|
});
|
|
|
|
it("logs a deadhead, the Auto top-up on landing, and fuel the player buys", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
await test.app.call("setRefuelPolicy", { shipId: o.shipId, policy: "auto" });
|
|
const { dispatch } = await test.app.call("createDispatch", {
|
|
kind: "reposition",
|
|
shipId: o.shipId,
|
|
pilotId: o.avatarId,
|
|
destinationId: "moon",
|
|
});
|
|
test.clock.now = dispatch.arrivalAt + 1_000;
|
|
await test.app.call("getPlayerState", {});
|
|
let save = await readSave(test.app);
|
|
expect(of(save, "flight")).toEqual([
|
|
expect.objectContaining({
|
|
deadhead: true,
|
|
payout: 0,
|
|
goodId: null,
|
|
fuelCost: dispatch.fuelCost,
|
|
}),
|
|
]);
|
|
expect(of(save, "refuel")).toEqual([
|
|
expect.objectContaining({
|
|
id: `${dispatch.id}:top-up`,
|
|
source: "top-up",
|
|
locationId: "moon",
|
|
}),
|
|
]);
|
|
|
|
await test.app.call("setRefuelPolicy", { shipId: o.shipId, policy: "manual" });
|
|
await edit(test.app, (s) => {
|
|
s.ships.find((x) => x.id === o.shipId)!.fuelFu -= 5;
|
|
});
|
|
await test.app.call("refuelShip", { shipId: o.shipId, amountFu: 5 });
|
|
save = await readSave(test.app);
|
|
const bought = of(save, "refuel").filter((e) => e.source === "bought");
|
|
expect(bought).toEqual([
|
|
expect.objectContaining({ shipId: o.shipId, pilotId: o.avatarId, locationId: "moon" }),
|
|
]);
|
|
expect(bought[0]!.boughtFu).toBeCloseTo(5);
|
|
expect(bought[0]!.cost).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("logs a rest the player orders and one forced by the duty limit", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
await test.app.call("restPilot", { pilotId: o.avatarId });
|
|
let save = await readSave(test.app);
|
|
const ordered = save.pilots.find((p) => p.id === o.avatarId)!.restEndsAt!;
|
|
expect(of(save, "rest")).toEqual([
|
|
expect.objectContaining({ pilotId: o.avatarId, at: test.clock.now, until: ordered }),
|
|
]);
|
|
|
|
test.clock.now = ordered + 1_000;
|
|
await edit(test.app, (s) => {
|
|
s.pilots.find((p) => p.id === o.avatarId)!.dutyHours = content.config.dutyLimitGameHours;
|
|
});
|
|
const { dispatch, save: after } = await deliver(test, o.avatarId, o.shipId);
|
|
save = after;
|
|
expect(of(save, "rest").at(-1)).toMatchObject({
|
|
id: `${dispatch.id}:rest`,
|
|
pilotId: o.avatarId,
|
|
});
|
|
});
|
|
|
|
it("logs an upgrade when its shop time ends", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
const { ship } = await test.app.call("startUpgrade", {
|
|
shipId: o.shipId,
|
|
upgradeId: "engines",
|
|
});
|
|
expect(of(await readSave(test.app), "upgrade")).toEqual([]);
|
|
test.clock.now = ship.inShopUntil! + 1_000;
|
|
await test.app.call("getPlayerState", {});
|
|
expect(of(await readSave(test.app), "upgrade")).toEqual([
|
|
expect.objectContaining({
|
|
at: ship.inShopUntil,
|
|
shipId: o.shipId,
|
|
pilotId: o.avatarId,
|
|
upgradeId: "engines",
|
|
level: 1,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("logs a discovery, a level-up with the skill points, and a goal ready to claim", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
let toLevel = 0;
|
|
while (pilotLevel(toLevel, content.config) < 1) toLevel++;
|
|
await edit(test.app, (s) => {
|
|
s.discoveredLocations = s.discoveredLocations.filter((d) => d.locationId !== "moon");
|
|
s.pilots.find((p) => p.id === o.avatarId)!.xp = toLevel - 1;
|
|
});
|
|
const { dispatch, save } = await deliver(test, o.avatarId, o.shipId, { firstContact: true });
|
|
expect(of(save, "discovery")).toEqual([
|
|
expect.objectContaining({ id: `${dispatch.id}:discovery`, locationId: "moon" }),
|
|
]);
|
|
expect(of(save, "level-up")).toEqual([
|
|
expect.objectContaining({
|
|
pilotId: o.avatarId,
|
|
level: 1,
|
|
skillPoints: skillPointsAvailable(1, 0, content.config),
|
|
}),
|
|
]);
|
|
const goals = of(save, "goal-ready");
|
|
expect(goals.length).toBeGreaterThan(0);
|
|
// Each goal once, however often the state is read.
|
|
await test.app.call("getPlayerState", {});
|
|
expect(of(await readSave(test.app), "goal-ready")).toEqual(goals);
|
|
});
|
|
|
|
it("prunes entries older than 7 days on save", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
await deliver(test, o.avatarId, o.shipId);
|
|
const old = of(await readSave(test.app), "flight")[0]!;
|
|
test.clock.now = old.at + 7 * DAY - HOUR;
|
|
await test.app.call("restPilot", { pilotId: o.avatarId });
|
|
expect((await readSave(test.app)).logbook.map((e) => e.id)).toContain(old.id);
|
|
test.clock.now = old.at + 7 * DAY + HOUR;
|
|
await test.app.call("getPlayerState", {});
|
|
const ids = (await readSave(test.app)).logbook.map((e) => e.id);
|
|
expect(ids).not.toContain(old.id);
|
|
expect(ids.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("writes the same entries in catch-up as in live play", async () => {
|
|
const once = newTestApp();
|
|
await owner(once);
|
|
await setCredits(once.app, content.config.independentPilots.dispatchOfficePrice + 50_000);
|
|
await once.app.call("buyDispatchOffice", {});
|
|
for (const id of ["pa", "pb"]) {
|
|
await addShip(once.app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` });
|
|
await addPilot(once.app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` });
|
|
await once.app.call("setPilotMode", { pilotId: id, mode: "independent" });
|
|
}
|
|
await once.app.call("startUpgrade", { shipId: "ship-pa", upgradeId: "engines" });
|
|
const { code } = await once.app.call("exportSave", {});
|
|
const live = newTestApp();
|
|
live.clock.now = once.clock.now;
|
|
await live.app.call("importSave", { code });
|
|
|
|
const end = once.clock.now + 6 * HOUR;
|
|
once.clock.now = end;
|
|
await once.app.call("getPlayerState", {});
|
|
while (live.clock.now < end) {
|
|
live.clock.now = Math.min(end, live.clock.now + 2 * 60_000);
|
|
await live.app.call("getPlayerState", {});
|
|
}
|
|
const a = (await readSave(once.app)).logbook;
|
|
expect(a.filter((e) => e.kind === "flight").length).toBeGreaterThan(10);
|
|
expect(a.some((e) => e.kind === "refuel" && e.source === "pilot")).toBe(true);
|
|
expect(a.some((e) => e.kind === "upgrade")).toBe(true);
|
|
expect((await readSave(live.app)).logbook).toEqual(a);
|
|
}, 60_000);
|
|
|
|
it("starts the next absence when the summary is closed", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
const start = (await readSave(test.app)).player.awaySince;
|
|
const { dispatch } = await deliver(test, o.avatarId, o.shipId);
|
|
await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] });
|
|
const { player } = await readSave(test.app);
|
|
expect(player.lastAbsence).toEqual({ from: start, to: test.clock.now });
|
|
expect(player.awaySince).toBe(test.clock.now);
|
|
});
|
|
});
|
|
|
|
describe("net earnings (Step 25.2, decision 0.5.142)", () => {
|
|
it("matches the delivery summary: credits after pilot pay and loan, less the fuel used", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
await edit(test.app, (s) => {
|
|
s.company.loanBalance = 50_000;
|
|
});
|
|
const { dispatch, save } = await deliver(test, o.avatarId, o.shipId);
|
|
const summary = (await test.app.call("getPlayerState", {})).summaries.find(
|
|
(s) => s.dispatchId === dispatch.id,
|
|
)!;
|
|
const [entry] = of(save, "flight");
|
|
expect(summary.loanRepayment).toBeGreaterThan(0);
|
|
expect(flightNet(entry!)).toBe(summary.credits - summary.fuelCost);
|
|
});
|
|
});
|
|
|
|
describe("getLogbook and what needs the player (Step 25.3, decision 0.5.140)", () => {
|
|
it("returns the entries, this absence and the last one closed", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
const start = (await readSave(test.app)).player.awaySince;
|
|
const { dispatch } = await deliver(test, o.avatarId, o.shipId);
|
|
let book = await test.app.call("getLogbook", {});
|
|
expect(book.entries.map((e) => e.id)).toContain(`${dispatch.id}:flight`);
|
|
expect(book.absence).toEqual({ from: start, to: test.clock.now });
|
|
expect(book.lastAbsence).toBeNull();
|
|
|
|
const closed = test.clock.now;
|
|
await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] });
|
|
test.clock.now += HOUR;
|
|
book = await test.app.call("getLogbook", {});
|
|
expect(book.lastAbsence).toEqual({ from: start, to: closed });
|
|
expect(book.absence).toEqual({ from: closed, to: test.clock.now });
|
|
});
|
|
|
|
it("finds a ship too low on fuel, one needing emergency fuel, and credits below the floor", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
await edit(test.app, (s) => {
|
|
s.ships.find((x) => x.id === o.shipId)!.fuelFu = 0;
|
|
});
|
|
let { problems } = await test.app.call("getLogbook", {});
|
|
// Earth sells fuel and the company can pay: a refuel away.
|
|
expect(problems).toEqual([{ kind: "low-fuel", shipId: o.shipId, locationId: "earth" }]);
|
|
|
|
await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice);
|
|
await test.app.call("buyDispatchOffice", {});
|
|
await addShip(test.app, { id: "s2", modelId: "courier", name: "Wren" });
|
|
await addPilot(test.app, { id: "p2", name: "Ada", assignedShipId: "s2" });
|
|
await test.app.call("setPilotMode", { pilotId: "p2", mode: "independent" });
|
|
await setCredits(test.app, 0);
|
|
({ problems } = await test.app.call("getLogbook", {}));
|
|
expect(problems).toEqual([
|
|
{ kind: "emergency-fuel", shipId: o.shipId, locationId: "earth" },
|
|
{
|
|
kind: "below-floor",
|
|
credits: 0,
|
|
floor: content.config.independentPilots.defaultSpendingFloor,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("finds a Manual ship whose board has no job it can take", async () => {
|
|
const test = newTestApp();
|
|
const o = await owner(test);
|
|
const heavy = testContract({ massTonnes: 10_000, expiresAt: test.clock.now + HOUR });
|
|
await putBoard(test.app, [heavy], test.clock.now);
|
|
expect((await test.app.call("getLogbook", {})).problems).toEqual([
|
|
{ kind: "no-jobs", shipId: o.shipId, locationId: "earth" },
|
|
]);
|
|
await putBoard(test.app, [testContract({ expiresAt: test.clock.now + HOUR })], test.clock.now);
|
|
await edit(test.app, (s) => {
|
|
s.contractBoards = s.contractBoards.slice(-1);
|
|
});
|
|
expect((await test.app.call("getLogbook", {})).problems).toEqual([]);
|
|
});
|
|
});
|